ferrum 0.7 → 0.10.2

Sign up to get free protection for your applications and to get access to all the features.
data/lib/ferrum.rb CHANGED
@@ -10,14 +10,20 @@ module Ferrum
10
10
  class NotImplementedError < Error; end
11
11
 
12
12
  class StatusError < Error
13
+ def initialize(url, message = nil)
14
+ super(message || "Request to #{url} failed to reach server, check DNS and server status")
15
+ end
16
+ end
17
+
18
+ class PendingConnectionsError < StatusError
19
+ attr_reader :pendings
20
+
13
21
  def initialize(url, pendings = [])
14
- message = if pendings.empty?
15
- "Request to #{url} failed to reach server, check DNS and/or server status"
16
- else
17
- "Request to #{url} reached server, but there are still pending connections: #{pendings.join(', ')}"
18
- end
22
+ @pendings = pendings
19
23
 
20
- super(message)
24
+ message = "Request to #{url} reached server, but there are still pending connections: #{pendings.join(', ')}"
25
+
26
+ super(url, message)
21
27
  end
22
28
  end
23
29
 
@@ -36,12 +42,34 @@ module Ferrum
36
42
  end
37
43
  end
38
44
 
45
+ class ProcessTimeoutError < Error
46
+ attr_reader :output
47
+
48
+ def initialize(timeout, output)
49
+ @output = output
50
+ super("Browser did not produce websocket url within #{timeout} seconds, try to increase `:process_timeout`. See https://github.com/rubycdp/ferrum#customization")
51
+ end
52
+ end
53
+
39
54
  class DeadBrowserError < Error
40
55
  def initialize(message = "Browser is dead or given window is closed")
41
56
  super
42
57
  end
43
58
  end
44
59
 
60
+ class NodeIsMovingError < Error
61
+ def initialize(node, prev, current)
62
+ @node, @prev, @current = node, prev, current
63
+ super(message)
64
+ end
65
+
66
+ def message
67
+ "#{@node.inspect} that you're trying to click is moving, hence " \
68
+ "we cannot. Previosuly it was at #{@prev.inspect} but now at " \
69
+ "#{@current.inspect}."
70
+ end
71
+ end
72
+
45
73
  class BrowserError < Error
46
74
  attr_reader :response
47
75
 
@@ -72,8 +100,8 @@ module Ferrum
72
100
  attr_reader :class_name, :message
73
101
 
74
102
  def initialize(response)
75
- super
76
103
  @class_name, @message = response.values_at("className", "description")
104
+ super(response.merge("message" => @message))
77
105
  end
78
106
  end
79
107
 
@@ -4,6 +4,7 @@ require "base64"
4
4
  require "forwardable"
5
5
  require "ferrum/page"
6
6
  require "ferrum/contexts"
7
+ require "ferrum/browser/xvfb"
7
8
  require "ferrum/browser/process"
8
9
  require "ferrum/browser/client"
9
10
 
@@ -16,19 +17,20 @@ module Ferrum
16
17
  extend Forwardable
17
18
  delegate %i[default_context] => :contexts
18
19
  delegate %i[targets create_target create_page page pages windows] => :default_context
19
- delegate %i[goto back forward refresh reload
20
- at_css at_xpath css xpath current_url title body doctype
20
+ delegate %i[go_to back forward refresh reload stop wait_for_reload
21
+ at_css at_xpath css xpath current_url current_title url title
22
+ body doctype set_content
21
23
  headers cookies network
22
24
  mouse keyboard
23
- screenshot pdf viewport_size
25
+ screenshot pdf mhtml viewport_size
24
26
  frames frame_by main_frame
25
- evaluate evaluate_on evaluate_async execute
27
+ evaluate evaluate_on evaluate_async execute evaluate_func
26
28
  add_script_tag add_style_tag bypass_csp
27
- on] => :page
29
+ on goto] => :page
28
30
  delegate %i[default_user_agent] => :process
29
31
 
30
- attr_reader :client, :process, :contexts, :logger, :js_errors,
31
- :slowmo, :base_url, :options, :window_size
32
+ attr_reader :client, :process, :contexts, :logger, :js_errors, :pending_connection_errors,
33
+ :slowmo, :base_url, :options, :window_size, :ws_max_receive_size
32
34
  attr_writer :timeout
33
35
 
34
36
  def initialize(options = nil)
@@ -39,9 +41,11 @@ module Ferrum
39
41
  @original_window_size = @window_size
40
42
 
41
43
  @options = Hash(options.merge(window_size: @window_size))
42
- @logger, @timeout = @options.values_at(:logger, :timeout)
44
+ @logger, @timeout, @ws_max_receive_size =
45
+ @options.values_at(:logger, :timeout, :ws_max_receive_size)
43
46
  @js_errors = @options.fetch(:js_errors, false)
44
- @slowmo = @options[:slowmo].to_i
47
+ @pending_connection_errors = @options.fetch(:pending_connection_errors, true)
48
+ @slowmo = @options[:slowmo].to_f
45
49
 
46
50
  if @options.key?(:base_url)
47
51
  self.base_url = @options[:base_url]
@@ -114,7 +118,7 @@ module Ferrum
114
118
  def start
115
119
  Ferrum.started
116
120
  @process = Process.start(@options)
117
- @client = Client.new(self, @process.ws_url, 0, false)
121
+ @client = Client.new(self, @process.ws_url)
118
122
  @contexts = Contexts.new(self)
119
123
  end
120
124
  end
@@ -9,12 +9,11 @@ module Ferrum
9
9
  class Client
10
10
  INTERRUPTIONS = %w[Fetch.requestPaused Fetch.authRequired].freeze
11
11
 
12
- def initialize(browser, ws_url, start_id = 0, allow_slowmo = true)
13
- @command_id = start_id
14
- @pendings = Concurrent::Hash.new
12
+ def initialize(browser, ws_url, id_starts_with: 0)
15
13
  @browser = browser
16
- @slowmo = @browser.slowmo if allow_slowmo && @browser.slowmo > 0
17
- @ws = WebSocket.new(ws_url, @browser.logger)
14
+ @command_id = id_starts_with
15
+ @pendings = Concurrent::Hash.new
16
+ @ws = WebSocket.new(ws_url, @browser.ws_max_receive_size, @browser.logger)
18
17
  @subscriber, @interruptor = Subscriber.build(2)
19
18
 
20
19
  @thread = Thread.new do
@@ -39,7 +38,6 @@ module Ferrum
39
38
  pending = Concurrent::IVar.new
40
39
  message = build_message(method, params)
41
40
  @pendings[message[:id]] = pending
42
- sleep(@slowmo) if @slowmo
43
41
  @ws.send_message(message)
44
42
  data = pending.value!(@browser.timeout)
45
43
  @pendings.delete(message[:id])
@@ -60,6 +58,10 @@ module Ferrum
60
58
  end
61
59
  end
62
60
 
61
+ def subscribed?(event)
62
+ [@interruptor, @subscriber].any? { |s| s.subscribed?(event) }
63
+ end
64
+
63
65
  def close
64
66
  @ws.close
65
67
  # Give a thread some time to handle a tail of messages
@@ -3,53 +3,54 @@
3
3
  module Ferrum
4
4
  class Browser
5
5
  class Command
6
- BROWSER_HOST = "127.0.0.1"
7
- BROWSER_PORT = "0"
8
6
  NOT_FOUND = "Could not find an executable for the browser. Try to make " \
9
- "it available on the PATH or set environment varible for " \
7
+ "it available on the PATH or set environment variable for " \
10
8
  "example BROWSER_PATH=\"/usr/bin/chrome\"".freeze
11
9
 
12
10
  # Currently only these browsers support CDP:
13
11
  # https://github.com/cyrus-and/chrome-remote-interface#implementations
14
12
  def self.build(options, user_data_dir)
15
- case options[:browser_name]
16
- when :firefox
17
- Firefox
18
- when :chrome, :opera, :edge, nil
19
- Chrome
20
- else
21
- raise NotImplementedError, "not supported browser"
22
- end.new(options, user_data_dir)
13
+ defaults = case options[:browser_name]
14
+ when :firefox
15
+ Options::Firefox.options
16
+ when :chrome, :opera, :edge, nil
17
+ Options::Chrome.options
18
+ else
19
+ raise NotImplementedError, "not supported browser"
20
+ end
21
+
22
+ new(defaults, options, user_data_dir)
23
23
  end
24
24
 
25
- attr_reader :path, :flags, :options
25
+ attr_reader :defaults, :path, :options
26
26
 
27
- def initialize(options, user_data_dir)
27
+ def initialize(defaults, options, user_data_dir)
28
28
  @flags = {}
29
+ @defaults = defaults
29
30
  @options, @user_data_dir = options, user_data_dir
30
- @path = options[:browser_path] || ENV["BROWSER_PATH"] || detect_path
31
+ @path = options[:browser_path] || ENV["BROWSER_PATH"] || defaults.detect_path
31
32
  raise Cliver::Dependency::NotFound.new(NOT_FOUND) unless @path
33
+ merge_options
34
+ end
32
35
 
33
- combine_flags
36
+ def xvfb?
37
+ !!options[:xvfb]
34
38
  end
35
39
 
36
40
  def to_a
37
- [path] + flags.map { |k, v| v.nil? ? "--#{k}" : "--#{k}=#{v}" }
41
+ [path] + @flags.map { |k, v| v.nil? ? "--#{k}" : "--#{k}=#{v}" }
38
42
  end
39
43
 
40
44
  private
41
45
 
42
- def detect_path
43
- if Ferrum.mac?
44
- self.class::MAC_BIN_PATH.find { |b| File.exist?(b) }
45
- else
46
- self.class::LINUX_BIN_PATH
47
- .find { |b| p = Cliver.detect(b) and break(p) }
46
+ def merge_options
47
+ @flags = defaults.merge_required(@flags, options, @user_data_dir)
48
+
49
+ unless options[:ignore_default_browser_options]
50
+ @flags = defaults.merge_default(@flags, options)
48
51
  end
49
- end
50
52
 
51
- def combine_flags
52
- raise NotImplementedError
53
+ @flags.merge!(options.fetch(:browser_options, {}))
53
54
  end
54
55
  end
55
56
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+
5
+ module Ferrum
6
+ class Browser
7
+ module Options
8
+ class Base
9
+ BROWSER_HOST = "127.0.0.1"
10
+ BROWSER_PORT = "0"
11
+
12
+ include Singleton
13
+
14
+ def self.options
15
+ instance
16
+ end
17
+
18
+ def to_h
19
+ self.class::DEFAULT_OPTIONS
20
+ end
21
+
22
+ def except(*keys)
23
+ to_h.reject { |n, _| keys.include?(n) }
24
+ end
25
+
26
+ def detect_path
27
+ if Ferrum.mac?
28
+ self.class::MAC_BIN_PATH.find { |n| File.exist?(n) }
29
+ else
30
+ self.class::LINUX_BIN_PATH.find do |name|
31
+ path = Cliver.detect(name) and break(path)
32
+ end
33
+ end
34
+ end
35
+
36
+ def merge_required(flags, options, user_data_dir)
37
+ raise NotImplementedError
38
+ end
39
+
40
+ def merge_default(flags, options)
41
+ raise NotImplementedError
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ferrum
4
+ class Browser
5
+ module Options
6
+ class Chrome < Base
7
+ DEFAULT_OPTIONS = {
8
+ "headless" => nil,
9
+ "disable-gpu" => nil,
10
+ "hide-scrollbars" => nil,
11
+ "mute-audio" => nil,
12
+ "enable-automation" => nil,
13
+ "disable-web-security" => nil,
14
+ "disable-session-crashed-bubble" => nil,
15
+ "disable-breakpad" => nil,
16
+ "disable-sync" => nil,
17
+ "no-first-run" => nil,
18
+ "use-mock-keychain" => nil,
19
+ "keep-alive-for-test" => nil,
20
+ "disable-popup-blocking" => nil,
21
+ "disable-extensions" => nil,
22
+ "disable-hang-monitor" => nil,
23
+ "disable-features" => "site-per-process,TranslateUI",
24
+ "disable-translate" => nil,
25
+ "disable-background-networking" => nil,
26
+ "enable-features" => "NetworkService,NetworkServiceInProcess",
27
+ "disable-background-timer-throttling" => nil,
28
+ "disable-backgrounding-occluded-windows" => nil,
29
+ "disable-client-side-phishing-detection" => nil,
30
+ "disable-default-apps" => nil,
31
+ "disable-dev-shm-usage" => nil,
32
+ "disable-ipc-flooding-protection" => nil,
33
+ "disable-prompt-on-repost" => nil,
34
+ "disable-renderer-backgrounding" => nil,
35
+ "force-color-profile" => "srgb",
36
+ "metrics-recording-only" => nil,
37
+ "safebrowsing-disable-auto-update" => nil,
38
+ "password-store" => "basic",
39
+ # Note: --no-sandbox is not needed if you properly setup a user in the container.
40
+ # https://github.com/ebidel/lighthouse-ci/blob/master/builder/Dockerfile#L35-L40
41
+ # "no-sandbox" => nil,
42
+ }.freeze
43
+
44
+ MAC_BIN_PATH = [
45
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
46
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
47
+ ].freeze
48
+ LINUX_BIN_PATH = %w[chromium google-chrome-unstable google-chrome-beta
49
+ google-chrome chrome chromium-browser
50
+ google-chrome-stable].freeze
51
+
52
+ def merge_required(flags, options, user_data_dir)
53
+ port = options.fetch(:port, BROWSER_PORT)
54
+ host = options.fetch(:host, BROWSER_HOST)
55
+ flags.merge("remote-debugging-port" => port,
56
+ "remote-debugging-address" => host,
57
+ # Doesn't work on MacOS, so we need to set it by CDP
58
+ "window-size" => options[:window_size].join(","),
59
+ "user-data-dir" => user_data_dir)
60
+ end
61
+
62
+ def merge_default(flags, options)
63
+ unless options.fetch(:headless, true)
64
+ defaults = except("headless", "disable-gpu")
65
+ end
66
+
67
+ defaults ||= DEFAULT_OPTIONS
68
+ defaults.merge(flags)
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ferrum
4
+ class Browser
5
+ module Options
6
+ class Firefox < Base
7
+ DEFAULT_OPTIONS = {
8
+ "headless" => nil,
9
+ }.freeze
10
+
11
+ MAC_BIN_PATH = [
12
+ "/Applications/Firefox.app/Contents/MacOS/firefox-bin"
13
+ ].freeze
14
+ LINUX_BIN_PATH = %w[firefox].freeze
15
+
16
+ def merge_required(flags, options, user_data_dir)
17
+ port = options.fetch(:port, BROWSER_PORT)
18
+ host = options.fetch(:host, BROWSER_HOST)
19
+ flags.merge("remote-debugger" => "#{host}:#{port}",
20
+ "profile" => user_data_dir)
21
+ end
22
+
23
+ def merge_default(flags, options)
24
+ unless options.fetch(:headless, true)
25
+ defaults = except("headless")
26
+ end
27
+
28
+ defaults ||= DEFAULT_OPTIONS
29
+ defaults.merge(flags)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -6,20 +6,21 @@ require "json"
6
6
  require "addressable"
7
7
  require "tmpdir"
8
8
  require "forwardable"
9
+ require "ferrum/browser/options/base"
10
+ require "ferrum/browser/options/chrome"
11
+ require "ferrum/browser/options/firefox"
9
12
  require "ferrum/browser/command"
10
- require "ferrum/browser/chrome"
11
- require "ferrum/browser/firefox"
12
13
 
13
14
  module Ferrum
14
15
  class Browser
15
16
  class Process
16
17
  KILL_TIMEOUT = 2
17
18
  WAIT_KILLED = 0.05
18
- PROCESS_TIMEOUT = ENV.fetch("FERRUM_PROCESS_TIMEOUT", 2).to_i
19
+ PROCESS_TIMEOUT = ENV.fetch("FERRUM_PROCESS_TIMEOUT", 10).to_i
19
20
 
20
21
  attr_reader :host, :port, :ws_url, :pid, :command,
21
22
  :default_user_agent, :browser_version, :protocol_version,
22
- :v8_version, :webkit_version
23
+ :v8_version, :webkit_version, :xvfb
23
24
 
24
25
 
25
26
  extend Forwardable
@@ -63,11 +64,13 @@ module Ferrum
63
64
  return
64
65
  end
65
66
 
67
+ @pid = @xvfb = @user_data_dir = nil
66
68
  @logger = options[:logger]
67
69
  @process_timeout = options.fetch(:process_timeout, PROCESS_TIMEOUT)
68
70
 
69
- tmpdir = Dir.mktmpdir
71
+ tmpdir = Dir.mktmpdir("ferrum_user_data_dir_")
70
72
  ObjectSpace.define_finalizer(self, self.class.directory_remover(tmpdir))
73
+ @user_data_dir = tmpdir
71
74
  @command = Command.build(options, tmpdir)
72
75
  end
73
76
 
@@ -81,7 +84,12 @@ module Ferrum
81
84
  process_options[:pgroup] = true unless Ferrum.windows?
82
85
  process_options[:out] = process_options[:err] = write_io
83
86
 
84
- @pid = ::Process.spawn(*@command.to_a, process_options)
87
+ if @command.xvfb?
88
+ @xvfb = Xvfb.start(@command.options)
89
+ ObjectSpace.define_finalizer(self, self.class.process_killer(@xvfb.pid))
90
+ end
91
+
92
+ @pid = ::Process.spawn(Hash(@xvfb&.to_env), *@command.to_a, process_options)
85
93
  ObjectSpace.define_finalizer(self, self.class.process_killer(@pid))
86
94
 
87
95
  parse_ws_url(read_io, @process_timeout)
@@ -92,7 +100,12 @@ module Ferrum
92
100
  end
93
101
 
94
102
  def stop
95
- kill if @pid
103
+ if @pid
104
+ kill(@pid)
105
+ kill(@xvfb.pid) if @xvfb&.pid
106
+ @pid = nil
107
+ end
108
+
96
109
  remove_user_data_dir if @user_data_dir
97
110
  ObjectSpace.undefine_finalizer(self)
98
111
  end
@@ -104,9 +117,8 @@ module Ferrum
104
117
 
105
118
  private
106
119
 
107
- def kill
108
- self.class.process_killer(@pid).call
109
- @pid = nil
120
+ def kill(pid)
121
+ self.class.process_killer(pid).call
110
122
  end
111
123
 
112
124
  def remove_user_data_dir
@@ -133,8 +145,8 @@ module Ferrum
133
145
  end
134
146
 
135
147
  unless ws_url
136
- @logger.puts output if @logger
137
- raise "Browser process did not produce websocket url within #{timeout} seconds"
148
+ @logger.puts(output) if @logger
149
+ raise ProcessTimeoutError.new(timeout, output)
138
150
  end
139
151
  end
140
152