ferrum 0.8 → 0.11

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.
@@ -83,19 +83,21 @@ module Ferrum
83
83
  @page.command("Fetch.enable", handleAuthRequests: true, patterns: [pattern])
84
84
  end
85
85
 
86
- def authorize(user:, password:, type: :server)
86
+ def authorize(user:, password:, type: :server, &block)
87
87
  unless AUTHORIZE_TYPE.include?(type)
88
88
  raise ArgumentError, ":type should be in #{AUTHORIZE_TYPE}"
89
89
  end
90
90
 
91
+ if !block_given? && !@page.subscribed?("Fetch.requestPaused")
92
+ raise ArgumentError, "Block is missing, call `authorize(...) { |r| r.continue } or subscribe to `on(:request)` events before calling it"
93
+ end
94
+
91
95
  @authorized_ids ||= {}
92
96
  @authorized_ids[type] ||= []
93
97
 
94
98
  intercept
95
99
 
96
- @page.on(:request) do |request|
97
- request.continue
98
- end
100
+ @page.on(:request, &block)
99
101
 
100
102
  @page.on(:auth) do |request, index, total|
101
103
  if request.auth_challenge?(type)
@@ -157,12 +159,27 @@ module Ferrum
157
159
  end
158
160
  end
159
161
 
162
+ @page.on("Network.loadingFailed") do |params|
163
+ exchange = select(params["requestId"]).last
164
+ exchange.error ||= Network::Error.new
165
+
166
+ exchange.error.id = params["requestId"]
167
+ exchange.error.type = params["type"]
168
+ exchange.error.error_text = params["errorText"]
169
+ exchange.error.monotonic_time = params["timestamp"]
170
+ exchange.error.canceled = params["canceled"]
171
+ end
172
+
160
173
  @page.on("Log.entryAdded") do |params|
161
174
  entry = params["entry"] || {}
162
175
  if entry["source"] == "network" && entry["level"] == "error"
163
176
  exchange = select(entry["networkRequestId"]).last
164
- error = Network::Error.new(entry)
165
- exchange.error = error
177
+ exchange.error ||= Network::Error.new
178
+
179
+ exchange.error.id = entry["networkRequestId"]
180
+ exchange.error.url = entry["url"]
181
+ exchange.error.description = entry["text"]
182
+ exchange.error.timestamp = entry["timestamp"]
166
183
  end
167
184
  end
168
185
  end
@@ -3,24 +3,17 @@
3
3
  module Ferrum
4
4
  class Network
5
5
  class Error
6
- def initialize(data)
7
- @data = data
8
- end
9
-
10
- def id
11
- @data["networkRequestId"]
12
- end
13
-
14
- def url
15
- @data["url"]
16
- end
6
+ attr_writer :canceled
7
+ attr_reader :time, :timestamp
8
+ attr_accessor :id, :url, :type, :error_text, :monotonic_time, :description
17
9
 
18
- def description
19
- @data["text"]
10
+ def canceled?
11
+ @canceled
20
12
  end
21
13
 
22
- def time
23
- @time ||= Time.strptime(@data["timestamp"].to_s, "%s")
14
+ def timestamp=(value)
15
+ @timestamp = value
16
+ @time = Time.strptime((value / 1000).to_s, "%s")
24
17
  end
25
18
  end
26
19
  end
@@ -39,7 +39,7 @@ module Ferrum
39
39
  requestId: request_id,
40
40
  responseHeaders: header_array(headers),
41
41
  })
42
- options = options.merge(body: Base64.encode64(options.fetch(:body, "")).strip) if has_body
42
+ options = options.merge(body: Base64.strict_encode64(options.fetch(:body, ""))) if has_body
43
43
 
44
44
  @status = :responded
45
45
  @page.command("Fetch.fulfillRequest", **options)
data/lib/ferrum/node.rb CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  module Ferrum
4
4
  class Node
5
+ MOVING_WAIT_DELAY = ENV.fetch("FERRUM_NODE_MOVING_WAIT", 0.01).to_f
6
+ MOVING_WAIT_ATTEMPTS = ENV.fetch("FERRUM_NODE_MOVING_ATTEMPTS", 50).to_i
7
+
5
8
  attr_reader :page, :target_id, :node_id, :description, :tag_name
6
9
 
7
10
  def initialize(frame, target_id, node_id, description)
@@ -27,6 +30,26 @@ module Ferrum
27
30
  tap { page.command("DOM.focus", slowmoable: true, nodeId: node_id) }
28
31
  end
29
32
 
33
+ def focusable?
34
+ focus
35
+ true
36
+ rescue BrowserError => e
37
+ e.message == "Element is not focusable" ? false : raise
38
+ end
39
+
40
+ def wait_for_stop_moving(delay: MOVING_WAIT_DELAY, attempts: MOVING_WAIT_ATTEMPTS)
41
+ Ferrum.with_attempts(errors: NodeMovingError, max: attempts, wait: 0) do
42
+ previous, current = get_content_quads_with(delay: delay)
43
+ raise NodeMovingError.new(self, previous, current) if previous != current
44
+ current
45
+ end
46
+ end
47
+
48
+ def moving?(delay: MOVING_WAIT_DELAY)
49
+ previous, current = get_content_quads_with(delay: delay)
50
+ previous == current
51
+ end
52
+
30
53
  def blur
31
54
  tap { evaluate("this.blur()") }
32
55
  end
@@ -121,16 +144,45 @@ module Ferrum
121
144
  end
122
145
 
123
146
  def find_position(x: nil, y: nil, position: :top)
124
- offset_x, offset_y = x, y
125
- quads = get_content_quads
147
+ points = wait_for_stop_moving.map { |q| to_points(q) }.first
148
+ get_position(points, x, y, position)
149
+ rescue CoordinatesNotFoundError
150
+ x, y = get_bounding_rect_coordinates
151
+ raise if x == 0 && y == 0
152
+ [x, y]
153
+ end
154
+
155
+ private
156
+
157
+ def get_bounding_rect_coordinates
158
+ evaluate <<~JS
159
+ [this.getBoundingClientRect().left + window.pageXOffset + (this.offsetWidth / 2),
160
+ this.getBoundingClientRect().top + window.pageYOffset + (this.offsetHeight / 2)]
161
+ JS
162
+ end
163
+
164
+ def get_content_quads
165
+ quads = page.command("DOM.getContentQuads", nodeId: node_id)["quads"]
166
+ raise CoordinatesNotFoundError, "Node is either not visible or not an HTMLElement" if quads.size == 0
167
+ quads
168
+ end
169
+
170
+ def get_content_quads_with(delay: MOVING_WAIT_DELAY)
171
+ previous = get_content_quads
172
+ sleep(delay)
173
+ current = get_content_quads
174
+ [previous, current]
175
+ end
176
+
177
+ def get_position(points, offset_x, offset_y, position)
126
178
  x = y = nil
127
179
 
128
180
  if offset_x && offset_y && position == :top
129
- point = quads.first
181
+ point = points.first
130
182
  x = point[:x] + offset_x.to_i
131
183
  y = point[:y] + offset_y.to_i
132
184
  else
133
- x, y = quads.inject([0, 0]) do |memo, point|
185
+ x, y = points.inject([0, 0]) do |memo, point|
134
186
  [memo[0] + point[:x],
135
187
  memo[1] + point[:y]]
136
188
  end
@@ -147,19 +199,11 @@ module Ferrum
147
199
  [x, y]
148
200
  end
149
201
 
150
- private
151
-
152
- def get_content_quads
153
- result = page.command("DOM.getContentQuads", nodeId: node_id)
154
- raise "Node is either not visible or not an HTMLElement" if result["quads"].size == 0
155
-
156
- # FIXME: Case when a few quads returned
157
- result["quads"].map do |quad|
158
- [{x: quad[0], y: quad[1]},
159
- {x: quad[2], y: quad[3]},
160
- {x: quad[4], y: quad[5]},
161
- {x: quad[6], y: quad[7]}]
162
- end.first
202
+ def to_points(quad)
203
+ [{x: quad[0], y: quad[1]},
204
+ {x: quad[2], y: quad[3]},
205
+ {x: quad[4], y: quad[5]},
206
+ {x: quad[6], y: quad[7]}]
163
207
  end
164
208
  end
165
209
  end
data/lib/ferrum/page.rb CHANGED
@@ -9,6 +9,7 @@ require "ferrum/dialog"
9
9
  require "ferrum/network"
10
10
  require "ferrum/page/frames"
11
11
  require "ferrum/page/screenshot"
12
+ require "ferrum/page/animation"
12
13
  require "ferrum/browser/client"
13
14
 
14
15
  module Ferrum
@@ -32,10 +33,10 @@ module Ferrum
32
33
  extend Forwardable
33
34
  delegate %i[at_css at_xpath css xpath
34
35
  current_url current_title url title body doctype set_content
35
- execution_id evaluate evaluate_on evaluate_async execute
36
+ execution_id evaluate evaluate_on evaluate_async execute evaluate_func
36
37
  add_script_tag add_style_tag] => :main_frame
37
38
 
38
- include Frames, Screenshot
39
+ include Frames, Screenshot, Animation
39
40
 
40
41
  attr_accessor :referrer
41
42
  attr_reader :target_id, :browser,
@@ -44,7 +45,7 @@ module Ferrum
44
45
 
45
46
  def initialize(target_id, browser)
46
47
  @frames = {}
47
- @main_frame = Frame.new(nil, self)
48
+ @main_frame = Frame.new(nil, self)
48
49
  @target_id, @browser = target_id, browser
49
50
  @event = Event.new.tap(&:set)
50
51
 
@@ -65,7 +66,7 @@ module Ferrum
65
66
  @browser.timeout
66
67
  end
67
68
 
68
- def goto(url = nil)
69
+ def go_to(url = nil)
69
70
  options = { url: combine_url!(url) }
70
71
  options.merge!(referrer: referrer) if referrer
71
72
  response = command("Page.navigate", wait: GOTO_WAIT, **options)
@@ -78,9 +79,12 @@ module Ferrum
78
79
  end
79
80
  response["frameId"]
80
81
  rescue TimeoutError
81
- pendings = network.traffic.select(&:pending?).map { |e| e.request.url }
82
- raise StatusError.new(options[:url], pendings) unless pendings.empty?
82
+ if @browser.pending_connection_errors
83
+ pendings = network.traffic.select(&:pending?).map { |e| e.request.url }
84
+ raise PendingConnectionsError.new(options[:url], pendings) unless pendings.empty?
85
+ end
83
86
  end
87
+ alias goto go_to
84
88
 
85
89
  def close
86
90
  @headers.clear
@@ -89,15 +93,12 @@ module Ferrum
89
93
  end
90
94
 
91
95
  def resize(width: nil, height: nil, fullscreen: false)
92
- result = @browser.command("Browser.getWindowForTarget", targetId: @target_id)
93
- @window_id, @bounds = result.values_at("windowId", "bounds")
94
-
95
96
  if fullscreen
96
97
  width, height = document_size
97
- @browser.command("Browser.setWindowBounds", windowId: @window_id, bounds: { windowState: "fullscreen" })
98
+ set_window_bounds(windowState: "fullscreen")
98
99
  else
99
- @browser.command("Browser.setWindowBounds", windowId: @window_id, bounds: { windowState: "normal" })
100
- @browser.command("Browser.setWindowBounds", windowId: @window_id, bounds: { width: width, height: height, windowState: "normal" })
100
+ set_window_bounds(windowState: "normal")
101
+ set_window_bounds(width: width, height: height)
101
102
  end
102
103
 
103
104
  command("Emulation.setDeviceMetricsOverride", slowmoable: true,
@@ -108,6 +109,14 @@ module Ferrum
108
109
  fitWindow: false)
109
110
  end
110
111
 
112
+ def position
113
+ @browser.command("Browser.getWindowBounds", windowId: window_id).fetch("bounds").values_at("left", "top")
114
+ end
115
+
116
+ def position=(left:, top:)
117
+ @browser.command("Browser.setWindowBounds", windowId: window_id, bounds: { left: left, top: top })
118
+ end
119
+
111
120
  def refresh
112
121
  command("Page.reload", wait: timeout, slowmoable: true)
113
122
  end
@@ -125,12 +134,26 @@ module Ferrum
125
134
  history_navigate(delta: 1)
126
135
  end
127
136
 
137
+ def wait_for_reload(sec = 1)
138
+ @event.reset if @event.set?
139
+ @event.wait(sec)
140
+ @event.set
141
+ end
142
+
128
143
  def bypass_csp(value = true)
129
144
  enabled = !!value
130
145
  command("Page.setBypassCSP", enabled: enabled)
131
146
  enabled
132
147
  end
133
148
 
149
+ def window_id
150
+ @browser.command("Browser.getWindowForTarget", targetId: @target_id)["windowId"]
151
+ end
152
+
153
+ def set_window_bounds(bounds = {})
154
+ @browser.command("Browser.setWindowBounds", windowId: window_id, bounds: bounds)
155
+ end
156
+
134
157
  def command(method, wait: 0, slowmoable: false, **params)
135
158
  iteration = @event.reset if wait > 0
136
159
  sleep(@browser.slowmo) if slowmoable && @browser.slowmo > 0
@@ -173,6 +196,10 @@ module Ferrum
173
196
  end
174
197
  end
175
198
 
199
+ def subscribed?(event)
200
+ @client.subscribed?(event)
201
+ end
202
+
176
203
  private
177
204
 
178
205
  def subscribe
@@ -255,7 +282,7 @@ module Ferrum
255
282
  nil_or_relative = url.nil? || url.relative?
256
283
 
257
284
  if nil_or_relative && !@browser.base_url
258
- raise "Set :base_url browser's option or use absolute url in `goto`, you passed: #{url_or_path}"
285
+ raise "Set :base_url browser's option or use absolute url in `go_to`, you passed: #{url_or_path}"
259
286
  end
260
287
 
261
288
  (nil_or_relative ? @browser.base_url.join(url.to_s) : url).to_s
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ferrum
4
+ class Page
5
+ module Animation
6
+ def playback_rate
7
+ command("Animation.getPlaybackRate")["playbackRate"]
8
+ end
9
+
10
+
11
+ def playback_rate=(value)
12
+ command("Animation.setPlaybackRate", playbackRate: value)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -71,17 +71,25 @@ module Ferrum
71
71
  end
72
72
 
73
73
  on("Runtime.executionContextCreated") do |params|
74
+ setting_up_main_frame = false
74
75
  context_id = params.dig("context", "id")
75
76
  frame_id = params.dig("context", "auxData", "frameId")
76
77
 
77
78
  unless @main_frame.id
78
- @main_frame.id = frame_id
79
- @frames[frame_id] = @main_frame
79
+ root_frame = command("Page.getFrameTree").dig("frameTree", "frame", "id")
80
+ if frame_id == root_frame
81
+ setting_up_main_frame = true
82
+ @main_frame.id = frame_id
83
+ @frames[frame_id] = @main_frame
84
+ end
80
85
  end
81
86
 
82
87
  frame = @frames[frame_id] || Frame.new(frame_id, self)
83
88
  frame.set_execution_id(context_id)
84
89
 
90
+ # Set event because `execution_id` might raise NoExecutionContextError
91
+ @event.set if setting_up_main_frame
92
+
85
93
  @frames[frame_id] ||= frame
86
94
  end
87
95
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ferrum/rbga"
4
+
3
5
  module Ferrum
4
6
  class Page
5
7
  module Screenshot
@@ -10,7 +12,7 @@ module Ferrum
10
12
  scale: 1.0
11
13
  }.freeze
12
14
 
13
- PAPEP_FORMATS = {
15
+ PAPER_FORMATS = {
14
16
  letter: { width: 8.50, height: 11.00 },
15
17
  legal: { width: 8.50, height: 14.00 },
16
18
  tabloid: { width: 11.00, height: 17.00 },
@@ -24,19 +26,33 @@ module Ferrum
24
26
  A6: { width: 4.13, height: 5.83 },
25
27
  }.freeze
26
28
 
29
+ STREAM_CHUNK = 128 * 1024
30
+
27
31
  def screenshot(**opts)
28
32
  path, encoding = common_options(**opts)
29
33
  options = screenshot_options(path, **opts)
30
- data = capture_screenshot(options, opts[:full])
34
+ data = capture_screenshot(options, opts[:full], opts[:background_color])
31
35
  return data if encoding == :base64
32
- save_file(path, data)
36
+
37
+ bin = Base64.decode64(data)
38
+ save_file(path, bin)
33
39
  end
34
40
 
35
41
  def pdf(**opts)
36
42
  path, encoding = common_options(**opts)
37
- options = pdf_options(**opts)
38
- data = command("Page.printToPDF", **options).fetch("data")
39
- return data if encoding == :base64
43
+ options = pdf_options(**opts).merge(transferMode: "ReturnAsStream")
44
+ handle = command("Page.printToPDF", **options).fetch("stream")
45
+
46
+ if path
47
+ stream_to_file(handle, path: path)
48
+ else
49
+ stream_to_memory(handle, encoding: encoding)
50
+ end
51
+ end
52
+
53
+ def mhtml(path: nil)
54
+ data = command("Page.captureSnapshot", format: :mhtml).fetch("data")
55
+ return data if path.nil?
40
56
  save_file(path, data)
41
57
  end
42
58
 
@@ -56,9 +72,31 @@ module Ferrum
56
72
  private
57
73
 
58
74
  def save_file(path, data)
59
- bin = Base64.decode64(data)
60
- return bin unless path
61
- File.open(path.to_s, "wb") { |f| f.write(bin) }
75
+ return data unless path
76
+ File.open(path.to_s, "wb") { |f| f.write(data) }
77
+ end
78
+
79
+ def stream_to_file(handle, path:)
80
+ File.open(path, "wb") { |f| stream_to(handle, f) }
81
+ true
82
+ end
83
+
84
+ def stream_to_memory(handle, encoding:)
85
+ data = String.new("") # Mutable string has << and compatible to File
86
+ stream_to(handle, data)
87
+ encoding == :base64 ? Base64.encode64(data) : data
88
+ end
89
+
90
+ def stream_to(handle, output)
91
+ loop do
92
+ result = command("IO.read", handle: handle, size: STREAM_CHUNK)
93
+
94
+ data_chunk = result["data"]
95
+ data_chunk = Base64.decode64(data_chunk) if result["base64Encoded"]
96
+ output << data_chunk
97
+
98
+ break if result["eof"]
99
+ end
62
100
  end
63
101
 
64
102
  def common_options(encoding: :base64, path: nil, **_)
@@ -76,7 +114,7 @@ module Ferrum
76
114
  raise ArgumentError, "Specify :format or :paper_width, :paper_height"
77
115
  end
78
116
 
79
- dimension = PAPEP_FORMATS.fetch(format)
117
+ dimension = PAPER_FORMATS.fetch(format)
80
118
  options.merge!(paper_width: dimension[:width],
81
119
  paper_height: dimension[:height])
82
120
  end
@@ -134,9 +172,11 @@ module Ferrum
134
172
  option.to_s.gsub(/(?:_|(\/))([a-z\d]*)/) { "#{$1}#{$2.capitalize}" }.to_sym
135
173
  end
136
174
 
137
- def capture_screenshot(options, full)
175
+ def capture_screenshot(options, full, background_color)
138
176
  maybe_resize_fullscreen(full) do
139
- command("Page.captureScreenshot", **options)
177
+ with_background_color(background_color) do
178
+ command("Page.captureScreenshot", **options)
179
+ end
140
180
  end.fetch("data")
141
181
  end
142
182
 
@@ -150,6 +190,18 @@ module Ferrum
150
190
  ensure
151
191
  resize(width: width, height: height) if full
152
192
  end
193
+
194
+ def with_background_color(color)
195
+ if color
196
+ raise ArgumentError, "Accept Ferrum::RGBA class only" unless color.is_a?(RGBA)
197
+
198
+ command("Emulation.setDefaultBackgroundColorOverride", color: color.to_h)
199
+ end
200
+
201
+ yield
202
+ ensure
203
+ command("Emulation.setDefaultBackgroundColorOverride") if color
204
+ end
153
205
  end
154
206
  end
155
207
  end