ferrum 0.12 → 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.
@@ -3,10 +3,26 @@
3
3
  module Ferrum
4
4
  class Page
5
5
  module Animation
6
+ #
7
+ # Returns playback rate for CSS animations, defaults to `1`.
8
+ #
9
+ # @return [Integer]
10
+ #
6
11
  def playback_rate
7
12
  command("Animation.getPlaybackRate")["playbackRate"]
8
13
  end
9
14
 
15
+ #
16
+ # Sets playback rate of CSS animations.
17
+ #
18
+ # @param [Integer] value
19
+ #
20
+ # @example
21
+ # browser = Ferrum::Browser.new
22
+ # browser.playback_rate = 2000
23
+ # browser.go_to("https://google.com")
24
+ # browser.playback_rate # => 2000
25
+ #
10
26
  def playback_rate=(value)
11
27
  command("Animation.setPlaybackRate", playbackRate: value)
12
28
  end
@@ -5,12 +5,54 @@ require "ferrum/frame"
5
5
  module Ferrum
6
6
  class Page
7
7
  module Frames
8
+ # The page's main frame, the top of the tree and the parent of all frames.
9
+ #
10
+ # @return [Frame]
8
11
  attr_reader :main_frame
9
12
 
13
+ #
14
+ # Returns all the frames current page have.
15
+ #
16
+ # @return [Array<Frame>]
17
+ #
18
+ # @example
19
+ # browser.go_to("https://www.w3schools.com/tags/tag_frame.asp")
20
+ # browser.frames # =>
21
+ # # [
22
+ # # #<Ferrum::Frame
23
+ # # @id="C6D104CE454A025FBCF22B98DE612B12"
24
+ # # @parent_id=nil @name=nil @state=:stopped_loading @execution_id=1>,
25
+ # # #<Ferrum::Frame
26
+ # # @id="C09C4E4404314AAEAE85928EAC109A93"
27
+ # # @parent_id="C6D104CE454A025FBCF22B98DE612B12" @state=:stopped_loading @execution_id=2>,
28
+ # # #<Ferrum::Frame
29
+ # # @id="2E9C7F476ED09D87A42F2FEE3C6FBC3C"
30
+ # # @parent_id="C6D104CE454A025FBCF22B98DE612B12" @state=:stopped_loading @execution_id=3>,
31
+ # # ...
32
+ # # ]
33
+ #
10
34
  def frames
11
35
  @frames.values
12
36
  end
13
37
 
38
+ #
39
+ # Find frame by given options.
40
+ #
41
+ # @param [String] id
42
+ # Unique frame's id that browser provides.
43
+ #
44
+ # @param [String] name
45
+ # Frame's name if there's one.
46
+ #
47
+ # @param [String] execution_id
48
+ # Frame's context execution id.
49
+ #
50
+ # @return [Frame, nil]
51
+ # The matching frame.
52
+ #
53
+ # @example
54
+ # browser.frame_by(id: "C6D104CE454A025FBCF22B98DE612B12")
55
+ #
14
56
  def frame_by(id: nil, name: nil, execution_id: nil)
15
57
  if id
16
58
  @frames[id]
@@ -25,6 +67,7 @@ module Ferrum
25
67
 
26
68
  def frames_subscribe
27
69
  subscribe_frame_attached
70
+ subscribe_frame_detached
28
71
  subscribe_frame_started_loading
29
72
  subscribe_frame_navigated
30
73
  subscribe_frame_stopped_loading
@@ -43,14 +86,26 @@ module Ferrum
43
86
  def subscribe_frame_attached
44
87
  on("Page.frameAttached") do |params|
45
88
  parent_frame_id, frame_id = params.values_at("parentFrameId", "frameId")
46
- @frames[frame_id] = Frame.new(frame_id, self, parent_frame_id)
89
+ @frames.put_if_absent(frame_id, Frame.new(frame_id, self, parent_frame_id))
90
+ end
91
+ end
92
+
93
+ def subscribe_frame_detached
94
+ on("Page.frameDetached") do |params|
95
+ frame = @frames[params["frameId"]]
96
+
97
+ if frame&.main?
98
+ frame.execution_id = nil
99
+ else
100
+ @frames.delete(params["frameId"])
101
+ end
47
102
  end
48
103
  end
49
104
 
50
105
  def subscribe_frame_started_loading
51
106
  on("Page.frameStartedLoading") do |params|
52
107
  frame = @frames[params["frameId"]]
53
- frame.state = :started_loading
108
+ frame.state = :started_loading if frame
54
109
  @event.reset
55
110
  end
56
111
  end
@@ -59,8 +114,11 @@ module Ferrum
59
114
  on("Page.frameNavigated") do |params|
60
115
  frame_id, name = params["frame"]&.values_at("id", "name")
61
116
  frame = @frames[frame_id]
62
- frame.state = :navigated
63
- frame.name = name unless name.to_s.empty?
117
+
118
+ if frame
119
+ frame.state = :navigated
120
+ frame.name = name
121
+ end
64
122
  end
65
123
  end
66
124
 
@@ -107,14 +165,12 @@ module Ferrum
107
165
  root_frame = command("Page.getFrameTree").dig("frameTree", "frame", "id")
108
166
  if frame_id == root_frame
109
167
  @main_frame.id = frame_id
110
- @frames[frame_id] = @main_frame
168
+ @frames.put_if_absent(frame_id, @main_frame)
111
169
  end
112
170
  end
113
171
 
114
- frame = @frames[frame_id] || Frame.new(frame_id, self)
172
+ frame = @frames.fetch_or_store(frame_id, Frame.new(frame_id, self))
115
173
  frame.execution_id = context_id
116
-
117
- @frames[frame_id] ||= frame
118
174
  end
119
175
  end
120
176
 
@@ -128,13 +184,12 @@ module Ferrum
128
184
 
129
185
  def subscribe_execution_contexts_cleared
130
186
  on("Runtime.executionContextsCleared") do
131
- @frames.delete_if { |_, f| !f.main? }
132
- @main_frame.execution_id = nil
187
+ @frames.each_value { |f| f.execution_id = nil }
133
188
  end
134
189
  end
135
190
 
136
191
  def idling?
137
- @frames.all? { |_, f| f.state == :stopped_loading }
192
+ @frames.values.all? { |f| f.state == :stopped_loading }
138
193
  end
139
194
  end
140
195
  end
@@ -26,6 +26,51 @@ module Ferrum
26
26
  A6: { width: 4.13, height: 5.83 }
27
27
  }.freeze
28
28
 
29
+ #
30
+ # Saves screenshot on a disk or returns it as base64.
31
+ #
32
+ # @param [Hash{Symbol => Object}] opts
33
+ #
34
+ # @option opts [String] :path
35
+ # The path to save a screenshot on the disk. `:encoding` will be set to
36
+ # `:binary` automatically.
37
+ #
38
+ # @option opts [:base64, :binary] :encoding
39
+ # The encoding the image should be returned in.
40
+ #
41
+ # @option opts ["jpeg", "png"] :format
42
+ # The format the image should be returned in.
43
+ #
44
+ # @option opts [Integer] :quality
45
+ # The image quality. **Note:** 0-100 works for jpeg only.
46
+ #
47
+ # @option opts [Boolean] :full
48
+ # Whether you need full page screenshot or a viewport.
49
+ #
50
+ # @option opts [String] :selector
51
+ # CSS selector for the given element.
52
+ #
53
+ # @option opts [Float] :scale
54
+ # Zoom in/out.
55
+ #
56
+ # @option opts [Ferrum::RGBA] :background_color
57
+ # Sets the background color.
58
+ #
59
+ # @example
60
+ # browser.go_to("https://google.com/")
61
+ #
62
+ # @example Save on the disk in PNG:
63
+ # browser.screenshot(path: "google.png") # => 134660
64
+ #
65
+ # @example Save on the disk in JPG:
66
+ # browser.screenshot(path: "google.jpg") # => 30902
67
+ #
68
+ # @example Save to Base64 the whole page not only viewport and reduce quality:
69
+ # browser.screenshot(full: true, quality: 60) # "iVBORw0KGgoAAAANS...
70
+ #
71
+ # @example Save with specific background color:
72
+ # browser.screenshot(background_color: Ferrum::RGBA.new(0, 0, 0, 0.0))
73
+ #
29
74
  def screenshot(**opts)
30
75
  path, encoding = common_options(**opts)
31
76
  options = screenshot_options(path, **opts)
@@ -36,6 +81,42 @@ module Ferrum
36
81
  save_file(path, bin)
37
82
  end
38
83
 
84
+ #
85
+ # Saves PDF on a disk or returns it as Base64.
86
+ #
87
+ # @param [Hash{Symbol => Object}] opts
88
+ #
89
+ # @option opts [String] :path
90
+ # The path to save a screenshot on the disk. `:encoding` will be set to
91
+ # `:binary` automatically.
92
+ #
93
+ # @option opts [:base64, :binary] :encoding
94
+ # The encoding the image should be returned in.
95
+ #
96
+ # @option opts [Boolean] :landscape (false)
97
+ # Page orientation.
98
+ #
99
+ # @option opts [Float] :scale
100
+ # Zoom in/out.
101
+ #
102
+ # @option opts [:letter, :legal, :tabloid, :ledger, :A0, :A1, :A2, :A3, :A4, :A5, :A6] :format
103
+ # The standard paper size.
104
+ #
105
+ # @option opts [Float] :paper_width
106
+ # Sets the paper's width.
107
+ #
108
+ # @option opts [Float] :paper_height
109
+ # Sets the paper's height.
110
+ #
111
+ # @note
112
+ # See other [native options](https://chromedevtools.github.io/devtools-protocol/tot/Page#method-printToPDF) you
113
+ # can pass.
114
+ #
115
+ # @example
116
+ # browser.go_to("https://google.com/")
117
+ # # Save to disk as a PDF
118
+ # browser.pdf(path: "google.pdf", paper_width: 1.0, paper_height: 1.0) # => true
119
+ #
39
120
  def pdf(**opts)
40
121
  path, encoding = common_options(**opts)
41
122
  options = pdf_options(**opts).merge(transferMode: "ReturnAsStream")
@@ -43,6 +124,16 @@ module Ferrum
43
124
  stream_to(path: path, encoding: encoding, handle: handle)
44
125
  end
45
126
 
127
+ #
128
+ # Saves MHTML on a disk or returns it as a string.
129
+ #
130
+ # @param [String, nil] path
131
+ # The path to save a file on the disk.
132
+ #
133
+ # @example
134
+ # browser.go_to("https://google.com/")
135
+ # browser.mhtml(path: "google.mhtml") # => 87742
136
+ #
46
137
  def mhtml(path: nil)
47
138
  data = command("Page.captureSnapshot", format: :mhtml).fetch("data")
48
139
  return data if path.nil?
@@ -19,6 +19,32 @@ module Ferrum
19
19
  @subscribed_tracing_complete = false
20
20
  end
21
21
 
22
+ #
23
+ # Accepts block, records trace and by default returns trace data from `Tracing.tracingComplete` event as output.
24
+ #
25
+ # @param [String, nil] path
26
+ # Save data on the disk.
27
+ #
28
+ # @param [:binary, :base64] encoding
29
+ # Encode output as Base64 or plain text.
30
+ #
31
+ # @param [Float, nil] timeout
32
+ # Wait until file streaming finishes in the specified time or raise
33
+ # error.
34
+ #
35
+ # @param [Boolean] screenshots
36
+ # capture screenshots in the trace.
37
+ #
38
+ # @param [Hash{String => Object}] trace_config
39
+ # config for [trace](https://chromedevtools.github.io/devtools-protocol/tot/Tracing/#type-TraceConfig),
40
+ # for categories see [getCategories](https://chromedevtools.github.io/devtools-protocol/tot/Tracing/#method-getCategories),
41
+ # only one trace config can be active at a time per browser.
42
+ #
43
+ # @return [String, true]
44
+ # The trace data from the `Tracing.tracingComplete` event.
45
+ # When `path` is specified returns `true` and stores trace data into
46
+ # file.
47
+ #
22
48
  def record(path: nil, encoding: :binary, timeout: nil, trace_config: nil, screenshots: false)
23
49
  @path = path
24
50
  @encoding = encoding
data/lib/ferrum/page.rb CHANGED
@@ -35,7 +35,7 @@ module Ferrum
35
35
  extend Forwardable
36
36
  delegate %i[at_css at_xpath css xpath
37
37
  current_url current_title url title body doctype content=
38
- execution_id evaluate evaluate_on evaluate_async execute evaluate_func
38
+ execution_id execution_id! evaluate evaluate_on evaluate_async execute evaluate_func
39
39
  add_script_tag add_style_tag] => :main_frame
40
40
 
41
41
  include Animation
@@ -43,23 +43,47 @@ module Ferrum
43
43
  include Frames
44
44
  include Stream
45
45
 
46
- attr_accessor :referrer
47
- attr_reader :target_id, :browser,
48
- :headers, :cookies, :network,
49
- :mouse, :keyboard, :event,
50
- :tracing
46
+ attr_accessor :referrer, :timeout
47
+ attr_reader :target_id, :browser, :event, :tracing
51
48
 
52
- def initialize(target_id, browser)
53
- @frames = {}
49
+ # Mouse object.
50
+ #
51
+ # @return [Mouse]
52
+ attr_reader :mouse
53
+
54
+ # Keyboard object.
55
+ #
56
+ # @return [Keyboard]
57
+ attr_reader :keyboard
58
+
59
+ # Network object.
60
+ #
61
+ # @return [Network]
62
+ attr_reader :network
63
+
64
+ # Headers object.
65
+ #
66
+ # @return [Headers]
67
+ attr_reader :headers
68
+
69
+ # Cookie store.
70
+ #
71
+ # @return [Cookies]
72
+ attr_reader :cookies
73
+
74
+ def initialize(target_id, browser, proxy: nil)
75
+ @frames = Concurrent::Map.new
54
76
  @main_frame = Frame.new(nil, self)
55
77
  @browser = browser
56
78
  @target_id = target_id
79
+ @timeout = @browser.timeout
57
80
  @event = Event.new.tap(&:set)
81
+ self.proxy = proxy
58
82
 
59
- host = @browser.process.host
60
- port = @browser.process.port
61
- ws_url = "ws://#{host}:#{port}/devtools/page/#{@target_id}"
62
- @client = Browser::Client.new(browser, ws_url, id_starts_with: 1000)
83
+ @client = Browser::Client.new(ws_url, self,
84
+ logger: @browser.options.logger,
85
+ ws_max_receive_size: @browser.options.ws_max_receive_size,
86
+ id_starts_with: 1000)
63
87
 
64
88
  @mouse = Mouse.new(self)
65
89
  @keyboard = Keyboard.new(self)
@@ -72,14 +96,20 @@ module Ferrum
72
96
  prepare_page
73
97
  end
74
98
 
75
- def timeout
76
- @browser.timeout
77
- end
78
-
79
99
  def context
80
100
  @browser.contexts.find_by(target_id: target_id)
81
101
  end
82
102
 
103
+ #
104
+ # Navigates the page to a URL.
105
+ #
106
+ # @param [String, nil] url
107
+ # The URL to navigate to. The url should include scheme unless you set
108
+ # `{Browser#base_url = url}` when configuring driver.
109
+ #
110
+ # @example
111
+ # browser.go_to("https://github.com/")
112
+ #
83
113
  def go_to(url = nil)
84
114
  options = { url: combine_url!(url) }
85
115
  options.merge!(referrer: referrer) if referrer
@@ -88,14 +118,15 @@ module Ferrum
88
118
  if %w[net::ERR_NAME_NOT_RESOLVED
89
119
  net::ERR_NAME_RESOLUTION_FAILED
90
120
  net::ERR_INTERNET_DISCONNECTED
91
- net::ERR_CONNECTION_TIMED_OUT].include?(response["errorText"])
121
+ net::ERR_CONNECTION_TIMED_OUT
122
+ net::ERR_FILE_NOT_FOUND].include?(response["errorText"])
92
123
  raise StatusError, options[:url]
93
124
  end
94
125
 
95
126
  response["frameId"]
96
127
  rescue TimeoutError
97
- if @browser.pending_connection_errors
98
- pendings = network.traffic.select(&:pending?).map { |e| e.request.url }
128
+ if @browser.options.pending_connection_errors
129
+ pendings = network.traffic.select(&:pending?).map(&:url).compact
99
130
  raise PendingConnectionsError.new(options[:url], pendings) unless pendings.empty?
100
131
  end
101
132
  end
@@ -125,29 +156,83 @@ module Ferrum
125
156
  fitWindow: false)
126
157
  end
127
158
 
159
+ #
160
+ # The current position of the browser window.
161
+ #
162
+ # @return [(Integer, Integer)]
163
+ # The left, top coordinates of the browser window.
164
+ #
165
+ # @example
166
+ # browser.position # => [10, 20]
167
+ #
128
168
  def position
129
169
  @browser.command("Browser.getWindowBounds", windowId: window_id).fetch("bounds").values_at("left", "top")
130
170
  end
131
171
 
172
+ #
173
+ # Sets the position of the browser window.
174
+ #
175
+ # @param [Hash{Symbol => Object}] options
176
+ #
177
+ # @option options [Integer] :left
178
+ # The number of pixels from the left-hand side of the screen.
179
+ #
180
+ # @option options [Integer] :top
181
+ # The number of pixels from the top of the screen.
182
+ #
183
+ # @example
184
+ # browser.position = { left: 10, top: 20 }
185
+ #
132
186
  def position=(options)
133
187
  @browser.command("Browser.setWindowBounds",
134
188
  windowId: window_id,
135
189
  bounds: { left: options[:left], top: options[:top] })
136
190
  end
137
191
 
192
+ #
193
+ # Reloads the current page.
194
+ #
195
+ # @example
196
+ # browser.go_to("https://github.com/")
197
+ # browser.refresh
198
+ #
138
199
  def refresh
139
200
  command("Page.reload", wait: timeout, slowmoable: true)
140
201
  end
141
202
  alias reload refresh
142
203
 
204
+ #
205
+ # Stop all navigations and loading pending resources on the page.
206
+ #
207
+ # @example
208
+ # browser.go_to("https://github.com/")
209
+ # browser.stop
210
+ #
143
211
  def stop
144
212
  command("Page.stopLoading", slowmoable: true)
145
213
  end
146
214
 
215
+ #
216
+ # Navigates to the previous URL in the browser's history.
217
+ #
218
+ # @example
219
+ # browser.go_to("https://github.com/")
220
+ # browser.at_xpath("//a").click
221
+ # browser.back
222
+ #
147
223
  def back
148
224
  history_navigate(delta: -1)
149
225
  end
150
226
 
227
+ #
228
+ # Navigates to the next URL in the browser's history.
229
+ #
230
+ # @example
231
+ # browser.go_to("https://github.com/")
232
+ # browser.at_xpath("//a").click
233
+ # browser.back
234
+ # browser.forward
235
+ #
151
236
  def forward
152
237
  history_navigate(delta: 1)
153
238
  end
@@ -158,6 +243,20 @@ module Ferrum
158
243
  @event.set
159
244
  end
160
245
 
246
+ #
247
+ # Enables/disables CSP bypass.
248
+ #
249
+ # @param [Boolean] enabled
250
+ #
251
+ # @return [Boolean]
252
+ #
253
+ # @example
254
+ # browser.bypass_csp # => true
255
+ # browser.go_to("https://github.com/ruby-concurrency/concurrent-ruby/blob/master/docs-source/promises.in.md")
256
+ # browser.refresh
257
+ # browser.add_script_tag(content: "window.__injected = 42")
258
+ # browser.evaluate("window.__injected") # => 42
259
+ #
161
260
  def bypass_csp(enabled: true)
162
261
  command("Page.setBypassCSP", enabled: enabled)
163
262
  enabled
@@ -173,16 +272,16 @@ module Ferrum
173
272
 
174
273
  def command(method, wait: 0, slowmoable: false, **params)
175
274
  iteration = @event.reset if wait.positive?
176
- sleep(@browser.slowmo) if slowmoable && @browser.slowmo.positive?
275
+ sleep(@browser.options.slowmo) if slowmoable && @browser.options.slowmo.positive?
177
276
  result = @client.command(method, params)
178
277
 
179
278
  if wait.positive?
180
- @event.wait(wait)
181
279
  # Wait a bit after command and check if iteration has
182
280
  # changed which means there was some network event for
183
281
  # the main frame and it started to load new content.
282
+ @event.wait(wait)
184
283
  if iteration != @event.iteration
185
- set = @event.wait(@browser.timeout)
284
+ set = @event.wait(timeout)
186
285
  raise TimeoutError unless set
187
286
  end
188
287
  end
@@ -218,19 +317,27 @@ module Ferrum
218
317
  @client.subscribed?(event)
219
318
  end
220
319
 
320
+ def use_proxy?
321
+ @proxy_host && @proxy_port
322
+ end
323
+
324
+ def use_authorized_proxy?
325
+ use_proxy? && @proxy_user && @proxy_password
326
+ end
327
+
221
328
  private
222
329
 
223
330
  def subscribe
224
331
  frames_subscribe
225
332
  network.subscribe
226
333
 
227
- if @browser.logger
334
+ if @browser.options.logger
228
335
  on("Runtime.consoleAPICalled") do |params|
229
- params["args"].each { |r| @browser.logger.puts(r["value"]) }
336
+ params["args"].each { |r| @browser.options.logger.puts(r["value"]) }
230
337
  end
231
338
  end
232
339
 
233
- if @browser.js_errors
340
+ if @browser.options.js_errors
234
341
  on("Runtime.exceptionThrown") do |params|
235
342
  # FIXME: https://jvns.ca/blog/2015/11/27/why-rubys-timeout-is-dangerous-and-thread-dot-raise-is-terrifying/
236
343
  Thread.main.raise JavaScriptError.new(
@@ -257,21 +364,22 @@ module Ferrum
257
364
  command("Log.enable")
258
365
  command("Network.enable")
259
366
 
260
- if @browser.proxy_options && @browser.proxy_options[:user] && @browser.proxy_options[:password]
261
- auth_options = @browser.proxy_options.slice(:user, :password)
262
- network.authorize(type: :proxy, **auth_options) do |request, _index, _total|
367
+ if use_authorized_proxy?
368
+ network.authorize(user: @proxy_user,
369
+ password: @proxy_password,
370
+ type: :proxy) do |request, _index, _total|
263
371
  request.continue
264
372
  end
265
373
  end
266
374
 
267
- if @browser.options[:save_path]
268
- unless Pathname.new(@browser.options[:save_path]).absolute?
375
+ if @browser.options.save_path
376
+ unless Pathname.new(@browser.options.save_path).absolute?
269
377
  raise Error, "supply absolute path for `:save_path` option"
270
378
  end
271
379
 
272
380
  @browser.command("Browser.setDownloadBehavior",
273
381
  browserContextId: context.id,
274
- downloadPath: browser.options[:save_path],
382
+ downloadPath: @browser.options.save_path,
275
383
  behavior: "allow", eventsEnabled: true)
276
384
  end
277
385
 
@@ -303,7 +411,7 @@ module Ferrum
303
411
  # We also evaluate script just in case because
304
412
  # `Page.addScriptToEvaluateOnNewDocument` doesn't work in popups.
305
413
  command("Runtime.evaluate", expression: extension,
306
- contextId: execution_id,
414
+ executionContextId: execution_id!,
307
415
  returnByValue: true)
308
416
  end
309
417
  end
@@ -336,5 +444,16 @@ module Ferrum
336
444
  def document_node_id
337
445
  command("DOM.getDocument", depth: 0).dig("root", "nodeId")
338
446
  end
447
+
448
+ def ws_url
449
+ "ws://#{@browser.process.host}:#{@browser.process.port}/devtools/page/#{@target_id}"
450
+ end
451
+
452
+ def proxy=(options)
453
+ @proxy_host = options&.[](:host) || @browser.options.proxy&.[](:host)
454
+ @proxy_port = options&.[](:port) || @browser.options.proxy&.[](:port)
455
+ @proxy_user = options&.[](:user) || @browser.options.proxy&.[](:user)
456
+ @proxy_password = options&.[](:password) || @browser.options.proxy&.[](:password)
457
+ end
339
458
  end
340
459
  end