ferrum 0.11 → 0.13

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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +174 -30
  4. data/lib/ferrum/browser/binary.rb +46 -0
  5. data/lib/ferrum/browser/client.rb +17 -16
  6. data/lib/ferrum/browser/command.rb +10 -12
  7. data/lib/ferrum/browser/options/base.rb +2 -11
  8. data/lib/ferrum/browser/options/chrome.rb +29 -18
  9. data/lib/ferrum/browser/options/firefox.rb +13 -9
  10. data/lib/ferrum/browser/options.rb +84 -0
  11. data/lib/ferrum/browser/process.rb +45 -40
  12. data/lib/ferrum/browser/subscriber.rb +1 -3
  13. data/lib/ferrum/browser/version_info.rb +71 -0
  14. data/lib/ferrum/browser/web_socket.rb +9 -12
  15. data/lib/ferrum/browser/xvfb.rb +4 -8
  16. data/lib/ferrum/browser.rb +193 -47
  17. data/lib/ferrum/context.rb +9 -4
  18. data/lib/ferrum/contexts.rb +12 -10
  19. data/lib/ferrum/cookies/cookie.rb +126 -0
  20. data/lib/ferrum/cookies.rb +93 -55
  21. data/lib/ferrum/dialog.rb +30 -0
  22. data/lib/ferrum/errors.rb +115 -0
  23. data/lib/ferrum/frame/dom.rb +177 -0
  24. data/lib/ferrum/frame/runtime.rb +58 -75
  25. data/lib/ferrum/frame.rb +118 -23
  26. data/lib/ferrum/headers.rb +30 -2
  27. data/lib/ferrum/keyboard.rb +56 -13
  28. data/lib/ferrum/mouse.rb +92 -7
  29. data/lib/ferrum/network/auth_request.rb +7 -2
  30. data/lib/ferrum/network/exchange.rb +97 -12
  31. data/lib/ferrum/network/intercepted_request.rb +10 -8
  32. data/lib/ferrum/network/request.rb +69 -0
  33. data/lib/ferrum/network/response.rb +85 -3
  34. data/lib/ferrum/network.rb +285 -36
  35. data/lib/ferrum/node.rb +69 -23
  36. data/lib/ferrum/page/animation.rb +16 -1
  37. data/lib/ferrum/page/frames.rb +111 -30
  38. data/lib/ferrum/page/screenshot.rb +142 -65
  39. data/lib/ferrum/page/stream.rb +38 -0
  40. data/lib/ferrum/page/tracing.rb +97 -0
  41. data/lib/ferrum/page.rb +224 -60
  42. data/lib/ferrum/proxy.rb +147 -0
  43. data/lib/ferrum/{rbga.rb → rgba.rb} +4 -2
  44. data/lib/ferrum/target.rb +7 -4
  45. data/lib/ferrum/utils/attempt.rb +20 -0
  46. data/lib/ferrum/utils/elapsed_time.rb +27 -0
  47. data/lib/ferrum/utils/platform.rb +28 -0
  48. data/lib/ferrum/version.rb +1 -1
  49. data/lib/ferrum.rb +4 -146
  50. metadata +63 -51
@@ -2,28 +2,32 @@
2
2
 
3
3
  module Ferrum
4
4
  class Browser
5
- module Options
5
+ class Options
6
6
  class Firefox < Base
7
7
  DEFAULT_OPTIONS = {
8
- "headless" => nil,
8
+ "headless" => nil
9
9
  }.freeze
10
10
 
11
11
  MAC_BIN_PATH = [
12
12
  "/Applications/Firefox.app/Contents/MacOS/firefox-bin"
13
13
  ].freeze
14
14
  LINUX_BIN_PATH = %w[firefox].freeze
15
+ WINDOWS_BIN_PATH = [
16
+ "C:/Program Files/Firefox Developer Edition/firefox.exe",
17
+ "C:/Program Files/Mozilla Firefox/firefox.exe"
18
+ ].freeze
19
+ PLATFORM_PATH = {
20
+ mac: MAC_BIN_PATH,
21
+ windows: WINDOWS_BIN_PATH,
22
+ linux: LINUX_BIN_PATH
23
+ }.freeze
15
24
 
16
25
  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)
26
+ flags.merge("remote-debugger" => "#{options.host}:#{options.port}", "profile" => user_data_dir)
21
27
  end
22
28
 
23
29
  def merge_default(flags, options)
24
- unless options.fetch(:headless, true)
25
- defaults = except("headless")
26
- end
30
+ defaults = except("headless") unless options.headless
27
31
 
28
32
  defaults ||= DEFAULT_OPTIONS
29
33
  defaults.merge(flags)
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ferrum
4
+ class Browser
5
+ class Options
6
+ HEADLESS = true
7
+ BROWSER_PORT = "0"
8
+ BROWSER_HOST = "127.0.0.1"
9
+ WINDOW_SIZE = [1024, 768].freeze
10
+ BASE_URL_SCHEMA = %w[http https].freeze
11
+ DEFAULT_TIMEOUT = ENV.fetch("FERRUM_DEFAULT_TIMEOUT", 5).to_i
12
+ PROCESS_TIMEOUT = ENV.fetch("FERRUM_PROCESS_TIMEOUT", 10).to_i
13
+ DEBUG_MODE = !ENV.fetch("FERRUM_DEBUG", nil).nil?
14
+
15
+ attr_reader :window_size, :timeout, :logger, :ws_max_receive_size,
16
+ :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
20
+
21
+ def initialize(options = nil)
22
+ @options = Hash(options&.dup)
23
+ @port = @options.fetch(:port, BROWSER_PORT)
24
+ @host = @options.fetch(:host, BROWSER_HOST)
25
+ @timeout = @options.fetch(:timeout, DEFAULT_TIMEOUT)
26
+ @window_size = @options.fetch(:window_size, WINDOW_SIZE)
27
+ @js_errors = @options.fetch(:js_errors, false)
28
+ @headless = @options.fetch(:headless, HEADLESS)
29
+ @pending_connection_errors = @options.fetch(:pending_connection_errors, true)
30
+ @process_timeout = @options.fetch(:process_timeout, PROCESS_TIMEOUT)
31
+ @browser_options = @options.fetch(:browser_options, {})
32
+ @slowmo = @options[:slowmo].to_f
33
+
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
+ )
39
+
40
+ @options[:window_size] = @window_size
41
+ @proxy = parse_proxy(@options[:proxy])
42
+ @logger = parse_logger(@options[:logger])
43
+ @base_url = parse_base_url(@options[:base_url]) if @options[:base_url]
44
+ @url = @options[:url].to_s if @options[:url]
45
+
46
+ @options.freeze
47
+ @browser_options.freeze
48
+ end
49
+
50
+ def to_h
51
+ @options
52
+ end
53
+
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(' | ')}"
58
+ end
59
+
60
+ parsed
61
+ end
62
+
63
+ def parse_proxy(options)
64
+ return unless options
65
+
66
+ raise ArgumentError, "proxy options must be a Hash" unless options.is_a?(Hash)
67
+
68
+ if options[:host].nil? && options[:port].nil?
69
+ raise ArgumentError, "proxy options must be a Hash with at least :host | :port"
70
+ end
71
+
72
+ options
73
+ end
74
+
75
+ private
76
+
77
+ def parse_logger(logger)
78
+ return logger if logger
79
+
80
+ !logger && DEBUG_MODE ? $stdout.tap { |s| s.sync = true } : logger
81
+ end
82
+ end
83
+ end
84
+ end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "cliver"
4
3
  require "net/http"
5
4
  require "json"
6
5
  require "addressable"
@@ -16,13 +15,11 @@ module Ferrum
16
15
  class Process
17
16
  KILL_TIMEOUT = 2
18
17
  WAIT_KILLED = 0.05
19
- PROCESS_TIMEOUT = ENV.fetch("FERRUM_PROCESS_TIMEOUT", 10).to_i
20
18
 
21
19
  attr_reader :host, :port, :ws_url, :pid, :command,
22
20
  :default_user_agent, :browser_version, :protocol_version,
23
21
  :v8_version, :webkit_version, :xvfb
24
22
 
25
-
26
23
  extend Forwardable
27
24
  delegate path: :command
28
25
 
@@ -32,41 +29,50 @@ module Ferrum
32
29
 
33
30
  def self.process_killer(pid)
34
31
  proc do
35
- begin
36
- if Ferrum.windows?
32
+ if Utils::Platform.windows?
33
+ # Process.kill is unreliable on Windows
34
+ ::Process.kill("KILL", pid) unless system("taskkill /f /t /pid #{pid} >NUL 2>NUL")
35
+ else
36
+ ::Process.kill("USR1", pid)
37
+ start = Utils::ElapsedTime.monotonic_time
38
+ while ::Process.wait(pid, ::Process::WNOHANG).nil?
39
+ sleep(WAIT_KILLED)
40
+ next unless Utils::ElapsedTime.timeout?(start, KILL_TIMEOUT)
41
+
37
42
  ::Process.kill("KILL", pid)
38
- else
39
- ::Process.kill("USR1", pid)
40
- start = Ferrum.monotonic_time
41
- while ::Process.wait(pid, ::Process::WNOHANG).nil?
42
- sleep(WAIT_KILLED)
43
- next unless Ferrum.timeout?(start, KILL_TIMEOUT)
44
- ::Process.kill("KILL", pid)
45
- ::Process.wait(pid)
46
- break
47
- end
43
+ ::Process.wait(pid)
44
+ break
48
45
  end
49
- rescue Errno::ESRCH, Errno::ECHILD
50
46
  end
47
+ rescue Errno::ESRCH, Errno::ECHILD
48
+ # nop
51
49
  end
52
50
  end
53
51
 
54
52
  def self.directory_remover(path)
55
- proc { FileUtils.remove_entry(path) rescue Errno::ENOENT }
53
+ proc {
54
+ begin
55
+ FileUtils.remove_entry(path)
56
+ rescue StandardError
57
+ Errno::ENOENT
58
+ end
59
+ }
56
60
  end
57
61
 
58
62
  def initialize(options)
59
- if options[:url]
60
- url = URI.join(options[:url].to_s, "/json/version")
63
+ @pid = @xvfb = @user_data_dir = nil
64
+
65
+ if options.url
66
+ url = URI.join(options.url, "/json/version")
61
67
  response = JSON.parse(::Net::HTTP.get(url))
62
- set_ws_url(response["webSocketDebuggerUrl"])
68
+ self.ws_url = response["webSocketDebuggerUrl"]
63
69
  parse_browser_versions
64
70
  return
65
71
  end
66
72
 
67
- @pid = @xvfb = @user_data_dir = nil
68
- @logger = options[:logger]
69
- @process_timeout = options.fetch(:process_timeout, PROCESS_TIMEOUT)
73
+ @logger = options.logger
74
+ @process_timeout = options.process_timeout
75
+ @env = Hash(options.env)
70
76
 
71
77
  tmpdir = Dir.mktmpdir("ferrum_user_data_dir_")
72
78
  ObjectSpace.define_finalizer(self, self.class.directory_remover(tmpdir))
@@ -81,7 +87,7 @@ module Ferrum
81
87
  begin
82
88
  read_io, write_io = IO.pipe
83
89
  process_options = { in: File::NULL }
84
- process_options[:pgroup] = true unless Ferrum.windows?
90
+ process_options[:pgroup] = true unless Utils::Platform.windows?
85
91
  process_options[:out] = process_options[:err] = write_io
86
92
 
87
93
  if @command.xvfb?
@@ -89,7 +95,8 @@ module Ferrum
89
95
  ObjectSpace.define_finalizer(self, self.class.process_killer(@xvfb.pid))
90
96
  end
91
97
 
92
- @pid = ::Process.spawn(Hash(@xvfb&.to_env), *@command.to_a, process_options)
98
+ env = Hash(@xvfb&.to_env).merge(@env)
99
+ @pid = ::Process.spawn(env, *@command.to_a, process_options)
93
100
  ObjectSpace.define_finalizer(self, self.class.process_killer(@pid))
94
101
 
95
102
  parse_ws_url(read_io, @process_timeout)
@@ -128,29 +135,29 @@ module Ferrum
128
135
 
129
136
  def parse_ws_url(read_io, timeout)
130
137
  output = ""
131
- start = Ferrum.monotonic_time
138
+ start = Utils::ElapsedTime.monotonic_time
132
139
  max_time = start + timeout
133
- regexp = /DevTools listening on (ws:\/\/.*)/
134
- while (now = Ferrum.monotonic_time) < max_time
140
+ regexp = %r{DevTools listening on (ws://.*)}
141
+ while (now = Utils::ElapsedTime.monotonic_time) < max_time
135
142
  begin
136
143
  output += read_io.read_nonblock(512)
137
144
  rescue IO::WaitReadable
138
- IO.select([read_io], nil, nil, max_time - now)
145
+ read_io.wait_readable(max_time - now)
139
146
  else
140
147
  if output.match(regexp)
141
- set_ws_url(output.match(regexp)[1].strip)
148
+ self.ws_url = output.match(regexp)[1].strip
142
149
  break
143
150
  end
144
151
  end
145
152
  end
146
153
 
147
- unless ws_url
148
- @logger.puts(output) if @logger
149
- raise ProcessTimeoutError.new(timeout, output)
150
- end
154
+ return if ws_url
155
+
156
+ @logger&.puts(output)
157
+ raise ProcessTimeoutError.new(timeout, output)
151
158
  end
152
159
 
153
- def set_ws_url(url)
160
+ def ws_url=(url)
154
161
  @ws_url = Addressable::URI.parse(url)
155
162
  @host = @ws_url.host
156
163
  @port = @ws_url.port
@@ -171,11 +178,9 @@ module Ferrum
171
178
 
172
179
  def close_io(*ios)
173
180
  ios.each do |io|
174
- begin
175
- io.close unless io.closed?
176
- rescue IOError
177
- raise unless RUBY_ENGINE == "jruby"
178
- end
181
+ io.close unless io.closed?
182
+ rescue IOError
183
+ raise unless RUBY_ENGINE == "jruby"
179
184
  end
180
185
  end
181
186
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "concurrent-ruby"
4
-
5
3
  module Ferrum
6
4
  class Browser
7
5
  class Subscriber
@@ -29,7 +27,7 @@ module Ferrum
29
27
  method, params = message.values_at("method", "params")
30
28
  total = @on[method].size
31
29
  @on[method].each_with_index do |block, index|
32
- # If there are a few callback we provide current index and total
30
+ # In case of multiple callbacks we provide current index and total
33
31
  block.call(params, index, total)
34
32
  end
35
33
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ferrum
4
+ class Browser
5
+ #
6
+ # The browser's version information returned by [Browser.getVersion].
7
+ #
8
+ # [Browser.getVersion]: https://chromedevtools.github.io/devtools-protocol/1-3/Browser/#method-getVersion
9
+ #
10
+ # @since 0.13
11
+ #
12
+ class VersionInfo
13
+ #
14
+ # Initializes the browser's version information.
15
+ #
16
+ # @param [Hash{String => Object}] properties
17
+ # The object properties returned by [Browser.getVersion](https://chromedevtools.github.io/devtools-protocol/1-3/Browser/#method-getVersion).
18
+ #
19
+ # @api private
20
+ #
21
+ def initialize(properties)
22
+ @properties = properties
23
+ end
24
+
25
+ #
26
+ # The Chrome DevTools protocol version.
27
+ #
28
+ # @return [String]
29
+ #
30
+ def protocol_version
31
+ @properties["protocolVersion"]
32
+ end
33
+
34
+ #
35
+ # The Chrome version.
36
+ #
37
+ # @return [String]
38
+ #
39
+ def product
40
+ @properties["product"]
41
+ end
42
+
43
+ #
44
+ # The Chrome revision properties.
45
+ #
46
+ # @return [String]
47
+ #
48
+ def revision
49
+ @properties["revision"]
50
+ end
51
+
52
+ #
53
+ # The Chrome `User-Agent` string.
54
+ #
55
+ # @return [String]
56
+ #
57
+ def user_agent
58
+ @properties["userAgent"]
59
+ end
60
+
61
+ #
62
+ # The JavaScript engine version.
63
+ #
64
+ # @return [String]
65
+ #
66
+ def js_version
67
+ @properties["jsVersion"]
68
+ end
69
+ end
70
+ end
71
+ end
@@ -21,9 +21,7 @@ module Ferrum
21
21
  @driver = ::WebSocket::Driver.client(self, max_length: max_receive_size)
22
22
  @messages = Queue.new
23
23
 
24
- if SKIP_LOGGING_SCREENSHOTS
25
- @screenshot_commands = Concurrent::Hash.new
26
- end
24
+ @screenshot_commands = Concurrent::Hash.new if SKIP_LOGGING_SCREENSHOTS
27
25
 
28
26
  @driver.on(:open, &method(:on_open))
29
27
  @driver.on(:message, &method(:on_message))
@@ -31,12 +29,13 @@ module Ferrum
31
29
 
32
30
  @thread = Thread.new do
33
31
  Thread.current.abort_on_exception = true
34
- if Thread.current.respond_to?(:report_on_exception=)
35
- Thread.current.report_on_exception = true
36
- end
32
+ Thread.current.report_on_exception = true if Thread.current.respond_to?(:report_on_exception=)
37
33
 
38
34
  begin
39
- while data = @sock.readpartial(512)
35
+ loop do
36
+ data = @sock.readpartial(512)
37
+ break unless data
38
+
40
39
  @driver.parse(data)
41
40
  end
42
41
  rescue EOFError, Errno::ECONNRESET, Errno::EPIPE
@@ -62,7 +61,7 @@ module Ferrum
62
61
  output.sub!(/{"data":"(.*)"}/, %("Set FERRUM_LOGGING_SCREENSHOTS=true to see screenshots in Base64"))
63
62
  end
64
63
 
65
- @logger&.puts(" ◀ #{Ferrum.elapsed_time} #{output}\n")
64
+ @logger&.puts(" ◀ #{Utils::ElapsedTime.elapsed_time} #{output}\n")
66
65
  end
67
66
 
68
67
  def on_close(_event)
@@ -71,13 +70,11 @@ module Ferrum
71
70
  end
72
71
 
73
72
  def send_message(data)
74
- if SKIP_LOGGING_SCREENSHOTS
75
- @screenshot_commands[data[:id]] = true
76
- end
73
+ @screenshot_commands[data[:id]] = true if SKIP_LOGGING_SCREENSHOTS
77
74
 
78
75
  json = data.to_json
79
76
  @driver.text(json)
80
- @logger&.puts("\n\n▶ #{Ferrum.elapsed_time} #{json}")
77
+ @logger&.puts("\n\n▶ #{Utils::ElapsedTime.elapsed_time} #{json}")
81
78
  end
82
79
 
83
80
  def write(data)
@@ -4,23 +4,19 @@ module Ferrum
4
4
  class Browser
5
5
  class Xvfb
6
6
  NOT_FOUND = "Could not find an executable for the Xvfb. Try to install " \
7
- "it with your package manager".freeze
7
+ "it with your package manager"
8
8
 
9
9
  def self.start(*args)
10
10
  new(*args).tap(&:start)
11
11
  end
12
12
 
13
- def self.xvfb_path
14
- Cliver.detect("Xvfb")
15
- end
16
-
17
13
  attr_reader :screen_size, :display_id, :pid
18
14
 
19
15
  def initialize(options)
20
- @path = self.class.xvfb_path
21
- raise Cliver::Dependency::NotFound.new(NOT_FOUND) unless @path
16
+ @path = Binary.find("Xvfb")
17
+ raise BinaryNotFoundError, NOT_FOUND unless @path
22
18
 
23
- @screen_size = options.fetch(:window_size, [1024, 768]).join("x") + "x24"
19
+ @screen_size = "#{options.window_size.join('x')}x24"
24
20
  @display_id = (Time.now.to_f * 1000).to_i % 100_000_000
25
21
  end
26
22