ferrum 0.12 → 0.13

Sign up to get free protection for your applications and to get access to all the features.
@@ -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