ferrum 0.12 → 0.13

Sign up to get free protection for your applications and to get access to all the features.
@@ -6,16 +6,14 @@ require "ferrum/page"
6
6
  require "ferrum/proxy"
7
7
  require "ferrum/contexts"
8
8
  require "ferrum/browser/xvfb"
9
+ require "ferrum/browser/options"
9
10
  require "ferrum/browser/process"
10
11
  require "ferrum/browser/client"
11
12
  require "ferrum/browser/binary"
13
+ require "ferrum/browser/version_info"
12
14
 
13
15
  module Ferrum
14
16
  class Browser
15
- DEFAULT_TIMEOUT = ENV.fetch("FERRUM_DEFAULT_TIMEOUT", 5).to_i
16
- WINDOW_SIZE = [1024, 768].freeze
17
- BASE_URL_SCHEMA = %w[http https].freeze
18
-
19
17
  extend Forwardable
20
18
  delegate %i[default_context] => :contexts
21
19
  delegate %i[targets create_target page pages windows] => :default_context
@@ -32,66 +30,146 @@ module Ferrum
32
30
  playback_rate playback_rate=] => :page
33
31
  delegate %i[default_user_agent] => :process
34
32
 
35
- attr_reader :client, :process, :contexts, :logger, :js_errors, :pending_connection_errors,
36
- :slowmo, :base_url, :options, :window_size, :ws_max_receive_size, :proxy_options,
37
- :proxy_server
38
- attr_writer :timeout
33
+ attr_reader :client, :process, :contexts, :options, :window_size, :base_url
34
+ attr_accessor :timeout
39
35
 
36
+ #
37
+ # Initializes the browser.
38
+ #
39
+ # @param [Hash{Symbol => Object}, nil] options
40
+ # Additional browser options.
41
+ #
42
+ # @option options [Boolean] :headless (true)
43
+ # Set browser as headless or not.
44
+ #
45
+ # @option options [Boolean] :xvfb (false)
46
+ # Run browser in a virtual framebuffer.
47
+ #
48
+ # @option options [(Integer, Integer)] :window_size ([1024, 768])
49
+ # The dimensions of the browser window in which to test, expressed as a
50
+ # 2-element array, e.g. `[1024, 768]`.
51
+ #
52
+ # @option options [Array<String, Hash>] :extensions
53
+ # An array of paths to files or JS source code to be preloaded into the
54
+ # browser e.g.: `["/path/to/script.js", { source: "window.secret = 'top'" }]`
55
+ #
56
+ # @option options [#puts] :logger
57
+ # When present, debug output is written to this object.
58
+ #
59
+ # @option options [Integer, Float] :slowmo
60
+ # Set a delay in seconds to wait before sending command.
61
+ # Useful companion of headless option, so that you have time to see
62
+ # changes.
63
+ #
64
+ # @option options [Numeric] :timeout (5)
65
+ # The number of seconds we'll wait for a response when communicating with
66
+ # browser.
67
+ #
68
+ # @option options [Boolean] :js_errors
69
+ # When true, JavaScript errors get re-raised in Ruby.
70
+ #
71
+ # @option options [Boolean] :pending_connection_errors (true)
72
+ # When main frame is still waiting for slow responses while timeout is
73
+ # reached {PendingConnectionsError} is raised. It's better to figure out
74
+ # why you have slow responses and fix or block them rather than turn this
75
+ # setting off.
76
+ #
77
+ # @option options [:chrome, :firefox] :browser_name (:chrome)
78
+ # Sets the browser's name. **Note:** only experimental support for
79
+ # `:firefox` for now.
80
+ #
81
+ # @option options [String] :browser_path
82
+ # Path to Chrome binary, you can also set ENV variable as
83
+ # `BROWSER_PATH=some/path/chrome bundle exec rspec`.
84
+ #
85
+ # @option options [Hash] :browser_options
86
+ # Additional command line options, [see them all](https://peter.sh/experiments/chromium-command-line-switches/)
87
+ # e.g. `{ "ignore-certificate-errors" => nil }`
88
+ #
89
+ # @option options [Boolean] :ignore_default_browser_options
90
+ # Ferrum has a number of default options it passes to the browser,
91
+ # if you set this to `true` then only options you put in
92
+ # `:browser_options` will be passed to the browser, except required ones
93
+ # of course.
94
+ #
95
+ # @option options [Integer] :port
96
+ # Remote debugging port for headless Chrome.
97
+ #
98
+ # @option options [String] :host
99
+ # Remote debugging address for headless Chrome.
100
+ #
101
+ # @option options [String] :url
102
+ # URL for a running instance of Chrome. If this is set, a browser process
103
+ # will not be spawned.
104
+ #
105
+ # @option options [Integer] :process_timeout
106
+ # How long to wait for the Chrome process to respond on startup.
107
+ #
108
+ # @option options [Integer] :ws_max_receive_size
109
+ # How big messages to accept from Chrome over the web socket, in bytes.
110
+ # Defaults to 64MB. Incoming messages larger this will cause a
111
+ # {Ferrum::DeadBrowserError}.
112
+ #
113
+ # @option options [Hash] :proxy
114
+ # Specify proxy settings, [read more](https://github.com/rubycdp/ferrum#proxy).
115
+ #
116
+ # @option options [String] :save_path
117
+ # Path to save attachments with [Content-Disposition](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition)
118
+ # header.
119
+ #
120
+ # @option options [Hash] :env
121
+ # Environment variables you'd like to pass through to the process.
122
+ #
40
123
  def initialize(options = nil)
41
- options ||= {}
42
-
43
- @client = nil
44
- @window_size = options.fetch(:window_size, WINDOW_SIZE)
45
- @original_window_size = @window_size
46
-
47
- @options = Hash(options.merge(window_size: @window_size))
48
- @logger, @timeout, @ws_max_receive_size =
49
- @options.values_at(:logger, :timeout, :ws_max_receive_size)
50
- @js_errors = @options.fetch(:js_errors, false)
51
-
52
- if @options[:proxy]
53
- @proxy_options = @options[:proxy]
54
-
55
- if @proxy_options[:server]
56
- @proxy_server = Proxy.start(**@proxy_options.slice(:host, :port, :user, :password))
57
- @proxy_options.merge!(host: @proxy_server.host, port: @proxy_server.port)
58
- end
59
-
60
- @options[:browser_options] ||= {}
61
- address = "#{@proxy_options[:host]}:#{@proxy_options[:port]}"
62
- @options[:browser_options].merge!("proxy-server" => address)
63
- @options[:browser_options].merge!("proxy-bypass-list" => @proxy_options[:bypass]) if @proxy_options[:bypass]
64
- end
65
-
66
- @pending_connection_errors = @options.fetch(:pending_connection_errors, true)
67
- @slowmo = @options[:slowmo].to_f
68
-
69
- self.base_url = @options[:base_url] if @options.key?(:base_url)
70
-
71
- if ENV.fetch("FERRUM_DEBUG", nil) && !@logger
72
- $stdout.sync = true
73
- @logger = $stdout
74
- @options[:logger] = @logger
75
- end
124
+ @options = Options.new(options)
125
+ @client = @process = @contexts = nil
76
126
 
77
- @options.freeze
127
+ @timeout = @options.timeout
128
+ @window_size = @options.window_size
129
+ @base_url = @options.base_url if @options.base_url
78
130
 
79
131
  start
80
132
  end
81
133
 
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
+ #
82
145
  def base_url=(value)
83
- parsed = Addressable::URI.parse(value)
84
- unless BASE_URL_SCHEMA.include?(parsed.normalized_scheme)
85
- raise "Set `base_url` should be absolute and include schema: #{BASE_URL_SCHEMA}"
86
- end
87
-
88
- @base_url = parsed
146
+ @base_url = options.parse_base_url(value)
89
147
  end
90
148
 
91
- def create_page(new_context: false)
92
- page = if new_context
93
- context = contexts.create
94
- context.create_page
149
+ #
150
+ # Creates a new page.
151
+ #
152
+ # @param [Boolean] new_context
153
+ # Whether to create a page in a new context or not.
154
+ #
155
+ # @param [Hash] proxy
156
+ # Whether to use proxy for a page. The page will be created in a new context if so.
157
+ #
158
+ # @return [Ferrum::Page]
159
+ # Created page.
160
+ #
161
+ def create_page(new_context: false, proxy: nil)
162
+ page = if new_context || proxy
163
+ params = {}
164
+
165
+ if proxy
166
+ options.parse_proxy(proxy)
167
+ params.merge!(proxyServer: "#{proxy[:host]}:#{proxy[:port]}")
168
+ params.merge!(proxyBypassList: proxy[:bypass]) if proxy[:bypass]
169
+ end
170
+
171
+ context = contexts.create(**params)
172
+ context.create_page(proxy: proxy)
95
173
  else
96
174
  default_context.create_page
97
175
  end
@@ -105,19 +183,28 @@ module Ferrum
105
183
  end
106
184
 
107
185
  def extensions
108
- @extensions ||= Array(@options[:extensions]).map do |ext|
186
+ @extensions ||= Array(options.extensions).map do |ext|
109
187
  (ext.is_a?(Hash) && ext[:source]) || File.read(ext)
110
188
  end
111
189
  end
112
190
 
191
+ #
192
+ # Evaluate JavaScript to modify things before a page load.
193
+ #
194
+ # @param [String] expression
195
+ # The JavaScript to add to each new document.
196
+ #
197
+ # @example
198
+ # browser.evaluate_on_new_document <<~JS
199
+ # Object.defineProperty(navigator, "languages", {
200
+ # get: function() { return ["tlh"]; }
201
+ # });
202
+ # JS
203
+ #
113
204
  def evaluate_on_new_document(expression)
114
205
  extensions << expression
115
206
  end
116
207
 
117
- def timeout
118
- @timeout || DEFAULT_TIMEOUT
119
- end
120
-
121
208
  def command(*args)
122
209
  @client.command(*args)
123
210
  rescue DeadBrowserError
@@ -125,8 +212,22 @@ module Ferrum
125
212
  raise
126
213
  end
127
214
 
215
+ #
216
+ # Closes browser tabs opened by the `Browser` instance.
217
+ #
218
+ # @example
219
+ # # connect to a long-running Chrome process
220
+ # browser = Ferrum::Browser.new(url: 'http://localhost:9222')
221
+ #
222
+ # browser.go_to("https://github.com/")
223
+ #
224
+ # # clean up, lest the tab stays there hanging forever
225
+ # browser.reset
226
+ #
227
+ # browser.quit
228
+ #
128
229
  def reset
129
- @window_size = @original_window_size
230
+ @window_size = options.window_size
130
231
  contexts.reset
131
232
  end
132
233
 
@@ -150,12 +251,25 @@ module Ferrum
150
251
  command("Browser.crash")
151
252
  end
152
253
 
254
+ #
255
+ # Gets the version information from the browser.
256
+ #
257
+ # @return [VersionInfo]
258
+ #
259
+ # @since 0.13
260
+ #
261
+ def version
262
+ VersionInfo.new(command("Browser.getVersion"))
263
+ end
264
+
153
265
  private
154
266
 
155
267
  def start
156
268
  Utils::ElapsedTime.start
157
- @process = Process.start(@options)
158
- @client = Client.new(self, @process.ws_url)
269
+ @process = Process.start(options)
270
+ @client = Client.new(@process.ws_url, self,
271
+ logger: options.logger,
272
+ ws_max_receive_size: options.ws_max_receive_size)
159
273
  @contexts = Contexts.new(self)
160
274
  end
161
275
  end
@@ -40,8 +40,9 @@ module Ferrum
40
40
  windows.map(&:page)
41
41
  end
42
42
 
43
- def create_page
44
- create_target.page
43
+ def create_page(**options)
44
+ target = create_target
45
+ target.page = target.build_page(**options)
45
46
  end
46
47
 
47
48
  def create_target
@@ -23,8 +23,8 @@ module Ferrum
23
23
  context
24
24
  end
25
25
 
26
- def create
27
- response = @browser.command("Target.createBrowserContext")
26
+ def create(**options)
27
+ response = @browser.command("Target.createBrowserContext", **options)
28
28
  context_id = response["browserContextId"]
29
29
  context = Context.new(@browser, self, context_id)
30
30
  @contexts[context_id] = context
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ferrum
4
+ class Cookies
5
+ #
6
+ # Represents a [cookie value](https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-Cookie).
7
+ #
8
+ class Cookie
9
+ # The parsed JSON attributes.
10
+ #
11
+ # @return [Hash{String => [String, Boolean, nil]}]
12
+ attr_reader :attributes
13
+
14
+ #
15
+ # Initializes the cookie.
16
+ #
17
+ # @param [Hash{String => String}] attributes
18
+ # The parsed JSON attributes.
19
+ #
20
+ def initialize(attributes)
21
+ @attributes = attributes
22
+ end
23
+
24
+ #
25
+ # The cookie's name.
26
+ #
27
+ # @return [String]
28
+ #
29
+ def name
30
+ attributes["name"]
31
+ end
32
+
33
+ #
34
+ # The cookie's value.
35
+ #
36
+ # @return [String]
37
+ #
38
+ def value
39
+ attributes["value"]
40
+ end
41
+
42
+ #
43
+ # The cookie's domain.
44
+ #
45
+ # @return [String]
46
+ #
47
+ def domain
48
+ attributes["domain"]
49
+ end
50
+
51
+ #
52
+ # The cookie's path.
53
+ #
54
+ # @return [String]
55
+ #
56
+ def path
57
+ attributes["path"]
58
+ end
59
+
60
+ #
61
+ # The `sameSite` configuration.
62
+ #
63
+ # @return ["Strict", "Lax", "None", nil]
64
+ #
65
+ def samesite
66
+ attributes["sameSite"]
67
+ end
68
+ alias same_site samesite
69
+
70
+ #
71
+ # The cookie's size.
72
+ #
73
+ # @return [Integer]
74
+ #
75
+ def size
76
+ attributes["size"]
77
+ end
78
+
79
+ #
80
+ # Specifies whether the cookie is secure or not.
81
+ #
82
+ # @return [Boolean]
83
+ #
84
+ def secure?
85
+ attributes["secure"]
86
+ end
87
+
88
+ #
89
+ # Specifies whether the cookie is HTTP-only or not.
90
+ #
91
+ # @return [Boolean]
92
+ #
93
+ def httponly?
94
+ attributes["httpOnly"]
95
+ end
96
+ alias http_only? httponly?
97
+
98
+ #
99
+ # Specifies whether the cookie is a session cookie or not.
100
+ #
101
+ # @return [Boolean]
102
+ #
103
+ def session?
104
+ attributes["session"]
105
+ end
106
+
107
+ #
108
+ # Specifies when the cookie will expire.
109
+ #
110
+ # @return [Time, nil]
111
+ #
112
+ def expires
113
+ Time.at(attributes["expires"]) if attributes["expires"].positive?
114
+ end
115
+
116
+ #
117
+ # Compares different cookie objects.
118
+ #
119
+ # @return [Boolean]
120
+ #
121
+ def ==(other)
122
+ other.class == self.class && other.attributes == attributes
123
+ end
124
+ end
125
+ end
126
+ end
@@ -1,68 +1,83 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ferrum/cookies/cookie"
4
+
3
5
  module Ferrum
4
6
  class Cookies
5
- class Cookie
6
- attr_reader :attributes
7
-
8
- def initialize(attributes)
9
- @attributes = attributes
10
- end
11
-
12
- def name
13
- @attributes["name"]
14
- end
15
-
16
- def value
17
- @attributes["value"]
18
- end
19
-
20
- def domain
21
- @attributes["domain"]
22
- end
23
-
24
- def path
25
- @attributes["path"]
26
- end
27
-
28
- def samesite
29
- @attributes["sameSite"]
30
- end
31
-
32
- def size
33
- @attributes["size"]
34
- end
35
-
36
- def secure?
37
- @attributes["secure"]
38
- end
39
-
40
- def httponly?
41
- @attributes["httpOnly"]
42
- end
43
-
44
- def session?
45
- @attributes["session"]
46
- end
47
-
48
- def expires
49
- Time.at(@attributes["expires"]) if @attributes["expires"].positive?
50
- end
51
- end
52
-
53
7
  def initialize(page)
54
8
  @page = page
55
9
  end
56
10
 
11
+ #
12
+ # Returns cookies hash.
13
+ #
14
+ # @return [Hash{String => Cookie}]
15
+ #
16
+ # @example
17
+ # browser.cookies.all # => {
18
+ # # "NID" => #<Ferrum::Cookies::Cookie:0x0000558624b37a40 @attributes={
19
+ # # "name"=>"NID", "value"=>"...", "domain"=>".google.com", "path"=>"/",
20
+ # # "expires"=>1583211046.575681, "size"=>178, "httpOnly"=>true, "secure"=>false, "session"=>false
21
+ # # }>
22
+ # # }
23
+ #
57
24
  def all
58
25
  cookies = @page.command("Network.getAllCookies")["cookies"]
59
26
  cookies.to_h { |c| [c["name"], Cookie.new(c)] }
60
27
  end
61
28
 
29
+ #
30
+ # Returns cookie.
31
+ #
32
+ # @param [String] name
33
+ # The cookie name to fetch.
34
+ #
35
+ # @return [Cookie, nil]
36
+ # The cookie with the matching name.
37
+ #
38
+ # @example
39
+ # browser.cookies["NID"] # =>
40
+ # # <Ferrum::Cookies::Cookie:0x0000558624b67a88 @attributes={
41
+ # # "name"=>"NID", "value"=>"...", "domain"=>".google.com",
42
+ # # "path"=>"/", "expires"=>1583211046.575681, "size"=>178,
43
+ # # "httpOnly"=>true, "secure"=>false, "session"=>false
44
+ # # }>
45
+ #
62
46
  def [](name)
63
47
  all[name]
64
48
  end
65
49
 
50
+ #
51
+ # Sets a cookie.
52
+ #
53
+ # @param [Hash{Symbol => Object}, Cookie] options
54
+ #
55
+ # @option options [String] :name
56
+ #
57
+ # @option options [String] :value
58
+ #
59
+ # @option options [String] :domain
60
+ #
61
+ # @option options [String] :path
62
+ #
63
+ # @option options [Integer] :expires
64
+ #
65
+ # @option options [Integer] :size
66
+ #
67
+ # @option options [Boolean] :httponly
68
+ #
69
+ # @option options [Boolean] :secure
70
+ #
71
+ # @option options [String] :samesite
72
+ #
73
+ #
74
+ # @example
75
+ # browser.cookies.set(name: "stealth", value: "omg", domain: "google.com") # => true
76
+ #
77
+ # @example
78
+ # nid_cookie = browser.cookies["NID"] # => <Ferrum::Cookies::Cookie:0x0000558624b67a88>
79
+ # browser.cookies.set(nid_cookie) # => true
80
+ #
66
81
  def set(options)
67
82
  cookie = (
68
83
  options.is_a?(Cookie) ? options.attributes : options
@@ -79,7 +94,21 @@ module Ferrum
79
94
  @page.command("Network.setCookie", **cookie)["success"]
80
95
  end
81
96
 
82
- # Supports :url, :domain and :path options
97
+ #
98
+ # Removes given cookie.
99
+ #
100
+ # @param [String] name
101
+ #
102
+ # @param [Hash{Symbol => Object}] options
103
+ # Additional keyword arguments.
104
+ #
105
+ # @option options [String] :domain
106
+ #
107
+ # @option options [String] :url
108
+ #
109
+ # @example
110
+ # browser.cookies.remove(name: "stealth", domain: "google.com") # => true
111
+ #
83
112
  def remove(name:, **options)
84
113
  raise "Specify :domain or :url option" if !options[:domain] && !options[:url] && !default_domain
85
114
 
@@ -91,6 +120,14 @@ module Ferrum
91
120
  true
92
121
  end
93
122
 
123
+ #
124
+ # Removes all cookies for current page.
125
+ #
126
+ # @return [true]
127
+ #
128
+ # @example
129
+ # browser.cookies.clear # => true
130
+ #
94
131
  def clear
95
132
  @page.command("Network.clearBrowserCookies")
96
133
  true
data/lib/ferrum/dialog.rb CHANGED
@@ -10,6 +10,22 @@ module Ferrum
10
10
  @default_prompt = params["defaultPrompt"]
11
11
  end
12
12
 
13
+ #
14
+ # Accept dialog with given text or default prompt if applicable
15
+ #
16
+ # @param [String, nil] prompt_text
17
+ #
18
+ # @example
19
+ # browser = Ferrum::Browser.new
20
+ # browser.on(:dialog) do |dialog|
21
+ # if dialog.match?(/bla-bla/)
22
+ # dialog.accept
23
+ # else
24
+ # dialog.dismiss
25
+ # end
26
+ # end
27
+ # browser.go_to("https://google.com")
28
+ #
13
29
  def accept(prompt_text = nil)
14
30
  options = { accept: true }
15
31
  response = prompt_text || default_prompt
@@ -17,6 +33,20 @@ module Ferrum
17
33
  @page.command("Page.handleJavaScriptDialog", slowmoable: true, **options)
18
34
  end
19
35
 
36
+ #
37
+ # Dismiss dialog.
38
+ #
39
+ # @example
40
+ # browser = Ferrum::Browser.new
41
+ # browser.on(:dialog) do |dialog|
42
+ # if dialog.match?(/bla-bla/)
43
+ # dialog.accept
44
+ # else
45
+ # dialog.dismiss
46
+ # end
47
+ # end
48
+ # browser.go_to("https://google.com")
49
+ #
20
50
  def dismiss
21
51
  @page.command("Page.handleJavaScriptDialog", slowmoable: true, accept: false)
22
52
  end