ferrum 0.11 → 0.13

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