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
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
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ferrum
4
+ class Error < StandardError; end
5
+ class NoSuchPageError < Error; end
6
+ class NoSuchTargetError < Error; end
7
+ class NotImplementedError < Error; end
8
+ class BinaryNotFoundError < Error; end
9
+ class EmptyPathError < Error; end
10
+
11
+ class StatusError < Error
12
+ def initialize(url, message = nil)
13
+ super(message || "Request to #{url} failed to reach server, check DNS and server status")
14
+ end
15
+ end
16
+
17
+ class PendingConnectionsError < StatusError
18
+ attr_reader :pendings
19
+
20
+ def initialize(url, pendings = [])
21
+ @pendings = pendings
22
+
23
+ message = "Request to #{url} reached server, but there are still pending connections: #{pendings.join(', ')}"
24
+
25
+ super(url, message)
26
+ end
27
+ end
28
+
29
+ class TimeoutError < Error
30
+ def message
31
+ "Timed out waiting for response. It's possible that this happened " \
32
+ "because something took a very long time (for example a page load " \
33
+ "was slow). If so, setting the :timeout option to a higher value might " \
34
+ "help."
35
+ end
36
+ end
37
+
38
+ class ScriptTimeoutError < Error
39
+ def message
40
+ "Timed out waiting for evaluated script to return a value"
41
+ end
42
+ end
43
+
44
+ class ProcessTimeoutError < Error
45
+ attr_reader :output
46
+
47
+ def initialize(timeout, output)
48
+ @output = output
49
+ super("Browser did not produce websocket url within #{timeout} seconds, try to increase `:process_timeout`. See https://github.com/rubycdp/ferrum#customization")
50
+ end
51
+ end
52
+
53
+ class DeadBrowserError < Error
54
+ def initialize(message = "Browser is dead or given window is closed")
55
+ super
56
+ end
57
+ end
58
+
59
+ class NodeMovingError < Error
60
+ def initialize(node, prev, current)
61
+ @node = node
62
+ @prev = prev
63
+ @current = current
64
+ super(message)
65
+ end
66
+
67
+ def message
68
+ "#{@node.inspect} that you're trying to click is moving, hence " \
69
+ "we cannot. Previously it was at #{@prev.inspect} but now at " \
70
+ "#{@current.inspect}."
71
+ end
72
+ end
73
+
74
+ class CoordinatesNotFoundError < Error
75
+ def initialize(message = "Could not compute content quads")
76
+ super
77
+ end
78
+ end
79
+
80
+ class BrowserError < Error
81
+ attr_reader :response
82
+
83
+ def initialize(response)
84
+ @response = response
85
+ super(response["message"])
86
+ end
87
+
88
+ def code
89
+ response["code"]
90
+ end
91
+
92
+ def data
93
+ response["data"]
94
+ end
95
+ end
96
+
97
+ class NodeNotFoundError < BrowserError; end
98
+
99
+ class NoExecutionContextError < BrowserError
100
+ def initialize(response = nil)
101
+ response ||= { "message" => "There's no context available" }
102
+ super(response)
103
+ end
104
+ end
105
+
106
+ class JavaScriptError < BrowserError
107
+ attr_reader :class_name, :message, :stack_trace
108
+
109
+ def initialize(response, stack_trace = nil)
110
+ @class_name, @message = response.values_at("className", "description")
111
+ @stack_trace = stack_trace
112
+ super(response.merge("message" => @message))
113
+ end
114
+ end
115
+ end
@@ -20,10 +20,59 @@
20
20
  module Ferrum
21
21
  class Frame
22
22
  module DOM
23
+ SCRIPT_SRC_TAG = <<~JS
24
+ const script = document.createElement("script");
25
+ script.src = arguments[0];
26
+ script.type = arguments[1];
27
+ script.onload = arguments[2];
28
+ document.head.appendChild(script);
29
+ JS
30
+ SCRIPT_TEXT_TAG = <<~JS
31
+ const script = document.createElement("script");
32
+ script.text = arguments[0];
33
+ script.type = arguments[1];
34
+ document.head.appendChild(script);
35
+ arguments[2]();
36
+ JS
37
+ STYLE_TAG = <<~JS
38
+ const style = document.createElement("style");
39
+ style.type = "text/css";
40
+ style.appendChild(document.createTextNode(arguments[0]));
41
+ document.head.appendChild(style);
42
+ arguments[1]();
43
+ JS
44
+ LINK_TAG = <<~JS
45
+ const link = document.createElement("link");
46
+ link.rel = "stylesheet";
47
+ link.href = arguments[0];
48
+ link.onload = arguments[1];
49
+ document.head.appendChild(link);
50
+ JS
51
+
52
+ #
53
+ # Returns current top window `location href`.
54
+ #
55
+ # @return [String]
56
+ # The window's current URL.
57
+ #
58
+ # @example
59
+ # browser.go_to("https://google.com/")
60
+ # browser.current_url # => "https://www.google.com/"
61
+ #
23
62
  def current_url
24
63
  evaluate("window.top.location.href")
25
64
  end
26
65
 
66
+ #
67
+ # Returns current top window title.
68
+ #
69
+ # @return [String]
70
+ # The window's current title.
71
+ #
72
+ # @example
73
+ # browser.go_to("https://google.com/")
74
+ # browser.current_title # => "Google"
75
+ #
27
76
  def current_title
28
77
  evaluate("window.top.document.title")
29
78
  end
@@ -32,10 +81,36 @@ module Ferrum
32
81
  evaluate("document.doctype && new XMLSerializer().serializeToString(document.doctype)")
33
82
  end
34
83
 
84
+ #
85
+ # Returns current page's html.
86
+ #
87
+ # @return [String]
88
+ # The HTML source of the current page.
89
+ #
90
+ # @example
91
+ # browser.go_to("https://google.com/")
92
+ # browser.body # => '<html itemscope="" itemtype="http://schema.org/WebPage" lang="ru"><head>...
93
+ #
35
94
  def body
36
95
  evaluate("document.documentElement.outerHTML")
37
96
  end
38
97
 
98
+ #
99
+ # Finds nodes by using a XPath selector.
100
+ #
101
+ # @param [String] selector
102
+ # The XPath selector.
103
+ #
104
+ # @param [Node, nil] within
105
+ # The parent node to search within.
106
+ #
107
+ # @return [Array<Node>]
108
+ # The matching nodes.
109
+ #
110
+ # @example
111
+ # browser.go_to("https://github.com/")
112
+ # browser.xpath("//a[@aria-label='Issues you created']") # => [Node]
113
+ #
39
114
  def xpath(selector, within: nil)
40
115
  expr = <<~JS
41
116
  function(selector, within) {
@@ -54,6 +129,22 @@ module Ferrum
54
129
  evaluate_func(expr, selector, within)
55
130
  end
56
131
 
132
+ #
133
+ # Finds a node by using a XPath selector.
134
+ #
135
+ # @param [String] selector
136
+ # The XPath selector.
137
+ #
138
+ # @param [Node, nil] within
139
+ # The parent node to search within.
140
+ #
141
+ # @return [Node, nil]
142
+ # The matching node.
143
+ #
144
+ # @example
145
+ # browser.go_to("https://github.com/")
146
+ # browser.at_xpath("//a[@aria-label='Issues you created']") # => Node
147
+ #
57
148
  def at_xpath(selector, within: nil)
58
149
  expr = <<~JS
59
150
  function(selector, within) {
@@ -65,6 +156,22 @@ module Ferrum
65
156
  evaluate_func(expr, selector, within)
66
157
  end
67
158
 
159
+ #
160
+ # Finds nodes by using a CSS path selector.
161
+ #
162
+ # @param [String] selector
163
+ # The CSS path selector.
164
+ #
165
+ # @param [Node, nil] within
166
+ # The parent node to search within.
167
+ #
168
+ # @return [Array<Node>]
169
+ # The matching nodes.
170
+ #
171
+ # @example
172
+ # browser.go_to("https://github.com/")
173
+ # browser.css("a[aria-label='Issues you created']") # => [Node]
174
+ #
68
175
  def css(selector, within: nil)
69
176
  expr = <<~JS
70
177
  function(selector, within) {
@@ -76,6 +183,22 @@ module Ferrum
76
183
  evaluate_func(expr, selector, within)
77
184
  end
78
185
 
186
+ #
187
+ # Finds a node by using a CSS path selector.
188
+ #
189
+ # @param [String] selector
190
+ # The CSS path selector.
191
+ #
192
+ # @param [Node, nil] within
193
+ # The parent node to search within.
194
+ #
195
+ # @return [Node, nil]
196
+ # The matching node.
197
+ #
198
+ # @example
199
+ # browser.go_to("https://github.com/")
200
+ # browser.at_css("a[aria-label='Issues you created']") # => Node
201
+ #
79
202
  def at_css(selector, within: nil)
80
203
  expr = <<~JS
81
204
  function(selector, within) {
@@ -86,6 +209,60 @@ module Ferrum
86
209
 
87
210
  evaluate_func(expr, selector, within)
88
211
  end
212
+
213
+ #
214
+ # Adds a `<script>` tag to the document.
215
+ #
216
+ # @param [String, nil] url
217
+ #
218
+ # @param [String, nil] path
219
+ #
220
+ # @param [String, nil] content
221
+ #
222
+ # @param [String] type
223
+ #
224
+ # @example
225
+ # browser.add_script_tag(url: "http://example.com/stylesheet.css") # => true
226
+ #
227
+ def add_script_tag(url: nil, path: nil, content: nil, type: "text/javascript")
228
+ expr, *args = if url
229
+ [SCRIPT_SRC_TAG, url, type]
230
+ elsif path || content
231
+ if path
232
+ content = File.read(path)
233
+ content += "\n//# sourceURL=#{path}"
234
+ end
235
+ [SCRIPT_TEXT_TAG, content, type]
236
+ end
237
+
238
+ evaluate_async(expr, @page.timeout, *args)
239
+ end
240
+
241
+ #
242
+ # Adds a `<style>` tag to the document.
243
+ #
244
+ # @param [String, nil] url
245
+ #
246
+ # @param [String, nil] path
247
+ #
248
+ # @param [String, nil] content
249
+ #
250
+ # @example
251
+ # browser.add_style_tag(content: "h1 { font-size: 40px; }") # => true
252
+ #
253
+ def add_style_tag(url: nil, path: nil, content: nil)
254
+ expr, *args = if url
255
+ [LINK_TAG, url]
256
+ elsif path || content
257
+ if path
258
+ content = File.read(path)
259
+ content += "\n//# sourceURL=#{path}"
260
+ end
261
+ [STYLE_TAG, content]
262
+ end
263
+
264
+ evaluate_async(expr, @page.timeout, *args)
265
+ end
89
266
  end
90
267
  end
91
268
  end
@@ -16,40 +16,38 @@ module Ferrum
16
16
  INTERMITTENT_ATTEMPTS = ENV.fetch("FERRUM_INTERMITTENT_ATTEMPTS", 6).to_i
17
17
  INTERMITTENT_SLEEP = ENV.fetch("FERRUM_INTERMITTENT_SLEEP", 0.1).to_f
18
18
 
19
- SCRIPT_SRC_TAG = <<~JS
20
- const script = document.createElement("script");
21
- script.src = arguments[0];
22
- script.type = arguments[1];
23
- script.onload = arguments[2];
24
- document.head.appendChild(script);
25
- JS
26
- SCRIPT_TEXT_TAG = <<~JS
27
- const script = document.createElement("script");
28
- script.text = arguments[0];
29
- script.type = arguments[1];
30
- document.head.appendChild(script);
31
- arguments[2]();
32
- JS
33
- STYLE_TAG = <<~JS
34
- const style = document.createElement("style");
35
- style.type = "text/css";
36
- style.appendChild(document.createTextNode(arguments[0]));
37
- document.head.appendChild(style);
38
- arguments[1]();
39
- JS
40
- LINK_TAG = <<~JS
41
- const link = document.createElement("link");
42
- link.rel = "stylesheet";
43
- link.href = arguments[0];
44
- link.onload = arguments[1];
45
- document.head.appendChild(link);
46
- JS
47
-
19
+ #
20
+ # Evaluate and return result for given JS expression.
21
+ #
22
+ # @param [String] expression
23
+ # The JavaScript to evaluate.
24
+ #
25
+ # @param [Array] args
26
+ # Additional arguments to pass to the JavaScript code.
27
+ #
28
+ # @example
29
+ # browser.evaluate("[window.scrollX, window.scrollY]")
30
+ #
48
31
  def evaluate(expression, *args)
49
- expression = "function() { return %s }" % expression
32
+ expression = format("function() { return %s }", expression)
50
33
  call(expression: expression, arguments: args)
51
34
  end
52
35
 
36
+ #
37
+ # Evaluate asynchronous expression and return result.
38
+ #
39
+ # @param [String] expression
40
+ # The JavaScript to evaluate.
41
+ #
42
+ # @param [Integer] wait
43
+ # How long we should wait for Promise to resolve or reject.
44
+ #
45
+ # @param [Array] args
46
+ # Additional arguments to pass to the JavaScript code.
47
+ #
48
+ # @example
49
+ # browser.evaluate_async(%(arguments[0]({foo: "bar"})), 5) # => { "foo" => "bar" }
50
+ #
53
51
  def evaluate_async(expression, wait, *args)
54
52
  template = <<~JS
55
53
  function() {
@@ -66,12 +64,24 @@ module Ferrum
66
64
  }
67
65
  JS
68
66
 
69
- expression = template % [wait * 1000, expression]
67
+ expression = format(template, wait * 1000, expression)
70
68
  call(expression: expression, arguments: args, awaitPromise: true)
71
69
  end
72
70
 
71
+ #
72
+ # Execute expression. Doesn't return the result.
73
+ #
74
+ # @param [String] expression
75
+ # The JavaScript to evaluate.
76
+ #
77
+ # @param [Array] args
78
+ # Additional arguments to pass to the JavaScript code.
79
+ #
80
+ # @example
81
+ # browser.execute(%(1 + 1)) # => true
82
+ #
73
83
  def execute(expression, *args)
74
- expression = "function() { %s }" % expression
84
+ expression = format("function() { %s }", expression)
75
85
  call(expression: expression, arguments: args, handle: false, returnByValue: true)
76
86
  true
77
87
  end
@@ -82,46 +92,17 @@ module Ferrum
82
92
 
83
93
  def evaluate_on(node:, expression:, by_value: true, wait: 0)
84
94
  options = { handle: true }
85
- expression = "function() { return %s }" % expression
95
+ expression = format("function() { return %s }", expression)
86
96
  options = { handle: false, returnByValue: true } if by_value
87
97
  call(expression: expression, on: node, wait: wait, **options)
88
98
  end
89
99
 
90
- def add_script_tag(url: nil, path: nil, content: nil, type: "text/javascript")
91
- expr, *args = if url
92
- [SCRIPT_SRC_TAG, url, type]
93
- elsif path || content
94
- if path
95
- content = File.read(path)
96
- content += "\n//# sourceURL=#{path}"
97
- end
98
- [SCRIPT_TEXT_TAG, content, type]
99
- end
100
-
101
- evaluate_async(expr, @page.timeout, *args)
102
- end
103
-
104
- def add_style_tag(url: nil, path: nil, content: nil)
105
- expr, *args = if url
106
- [LINK_TAG, url]
107
- elsif path || content
108
- if path
109
- content = File.read(path)
110
- content += "\n//# sourceURL=#{path}"
111
- end
112
- [STYLE_TAG, content]
113
- end
114
-
115
- evaluate_async(expr, @page.timeout, *args)
116
- end
117
-
118
100
  private
119
101
 
120
102
  def call(expression:, arguments: [], on: nil, wait: 0, handle: true, **options)
121
103
  errors = [NodeNotFoundError, NoExecutionContextError]
122
- attempts, sleep = INTERMITTENT_ATTEMPTS, INTERMITTENT_SLEEP
123
104
 
124
- Ferrum.with_attempts(errors: errors, max: attempts, wait: sleep) do
105
+ Utils::Attempt.with_retry(errors: errors, max: INTERMITTENT_ATTEMPTS, wait: INTERMITTENT_SLEEP) do
125
106
  params = options.dup
126
107
 
127
108
  if on
@@ -131,7 +112,7 @@ module Ferrum
131
112
  end
132
113
 
133
114
  if params[:executionContextId].nil? && params[:objectId].nil?
134
- params = params.merge(executionContextId: execution_id)
115
+ params = params.merge(executionContextId: execution_id!)
135
116
  end
136
117
 
137
118
  response = @page.command("Runtime.callFunctionOn",
@@ -141,7 +122,7 @@ module Ferrum
141
122
  handle_error(response)
142
123
  response = response["result"]
143
124
 
144
- handle ? handle_response(response) : response.dig("value")
125
+ handle ? handle_response(response) : response["value"]
145
126
  end
146
127
  end
147
128
 
@@ -154,7 +135,7 @@ module Ferrum
154
135
  when /\AError: timed out promise/
155
136
  raise ScriptTimeoutError
156
137
  else
157
- raise JavaScriptError.new(result)
138
+ raise JavaScriptError.new(result, response.dig("exceptionDetails", "stackTrace"))
158
139
  end
159
140
  end
160
141
 
@@ -171,16 +152,17 @@ module Ferrum
171
152
 
172
153
  case response["subtype"]
173
154
  when "node"
174
- # We cannot store object_id in the node because page can be reloaded
175
- # and node destroyed so we need to retrieve it each time for given id.
176
- # Though we can try to subscribe to `DOM.childNodeRemoved` and
177
- # `DOM.childNodeInserted` in the future.
178
- node_id = @page.command("DOM.requestNode", objectId: object_id)["nodeId"]
179
- description = @page.command("DOM.describeNode", nodeId: node_id)["node"]
180
- Node.new(self, @page.target_id, node_id, description)
155
+ # We cannot store object_id in the node because page can be reloaded
156
+ # and node destroyed so we need to retrieve it each time for given id.
157
+ # Though we can try to subscribe to `DOM.childNodeRemoved` and
158
+ # `DOM.childNodeInserted` in the future.
159
+ node_id = @page.command("DOM.requestNode", objectId: object_id)["nodeId"]
160
+ description = @page.command("DOM.describeNode", nodeId: node_id)["node"]
161
+ Node.new(self, @page.target_id, node_id, description)
181
162
  when "array"
182
163
  reduce_props(object_id, []) do |memo, key, value|
183
- next(memo) unless (Integer(key) rescue nil)
164
+ next(memo) unless Integer(key, exception: false)
165
+
184
166
  value = value["objectId"] ? handle_response(value) : value["value"]
185
167
  memo.insert(key.to_i, value)
186
168
  end.compact
@@ -212,11 +194,12 @@ module Ferrum
212
194
 
213
195
  def reduce_props(object_id, to)
214
196
  if cyclic?(object_id).dig("result", "value")
215
- return to.is_a?(Array) ? [cyclic_object] : cyclic_object
197
+ to.is_a?(Array) ? [cyclic_object] : cyclic_object
216
198
  else
217
199
  props = @page.command("Runtime.getProperties", ownProperties: true, objectId: object_id)
218
200
  props["result"].reduce(to) do |memo, prop|
219
201
  next(memo) unless prop["enumerable"]
202
+
220
203
  yield(memo, prop["name"], prop["value"])
221
204
  end
222
205
  end