ferrum 0.6 → 0.9

Sign up to get free protection for your applications and to get access to all the features.
@@ -7,9 +7,13 @@ module Ferrum
7
7
  class Subscriber
8
8
  include Concurrent::Async
9
9
 
10
+ def self.build(size)
11
+ (0..size).map { new }
12
+ end
13
+
10
14
  def initialize
11
15
  super
12
- @on = Hash.new { |h, k| h[k] = [] }
16
+ @on = Concurrent::Hash.new { |h, k| h[k] = Concurrent::Array.new }
13
17
  end
14
18
 
15
19
  def on(event, &block)
@@ -8,24 +8,32 @@ module Ferrum
8
8
  class Browser
9
9
  class WebSocket
10
10
  WEBSOCKET_BUG_SLEEP = 0.01
11
+ SKIP_LOGGING_SCREENSHOTS = !ENV["FERRUM_LOGGING_SCREENSHOTS"]
11
12
 
12
13
  attr_reader :url, :messages
13
14
 
14
- def initialize(url, logger)
15
+ def initialize(url, max_receive_size, logger)
15
16
  @url = url
16
17
  @logger = logger
17
18
  uri = URI.parse(@url)
18
19
  @sock = TCPSocket.new(uri.host, uri.port)
19
- @driver = ::WebSocket::Driver.client(self)
20
+ max_receive_size ||= ::WebSocket::Driver::MAX_LENGTH
21
+ @driver = ::WebSocket::Driver.client(self, max_length: max_receive_size)
20
22
  @messages = Queue.new
21
23
 
24
+ if SKIP_LOGGING_SCREENSHOTS
25
+ @screenshot_commands = Concurrent::Hash.new
26
+ end
27
+
22
28
  @driver.on(:open, &method(:on_open))
23
29
  @driver.on(:message, &method(:on_message))
24
30
  @driver.on(:close, &method(:on_close))
25
31
 
26
32
  @thread = Thread.new do
27
33
  Thread.current.abort_on_exception = true
28
- Thread.current.report_on_exception = true if Thread.current.respond_to?(:report_on_exception=)
34
+ if Thread.current.respond_to?(:report_on_exception=)
35
+ Thread.current.report_on_exception = true
36
+ end
29
37
 
30
38
  begin
31
39
  while data = @sock.readpartial(512)
@@ -47,7 +55,14 @@ module Ferrum
47
55
  def on_message(event)
48
56
  data = JSON.parse(event.data)
49
57
  @messages.push(data)
50
- @logger&.puts(" ◀ #{Ferrum.elapsed_time} #{event.data}\n")
58
+
59
+ output = event.data
60
+ if SKIP_LOGGING_SCREENSHOTS && @screenshot_commands[data["id"]]
61
+ @screenshot_commands.delete(data["id"])
62
+ output.sub!(/{"data":"(.*)"}/, %("Set FERRUM_LOGGING_SCREENSHOTS=true to see screenshots in Base64"))
63
+ end
64
+
65
+ @logger&.puts(" ◀ #{Ferrum.elapsed_time} #{output}\n")
51
66
  end
52
67
 
53
68
  def on_close(_event)
@@ -56,6 +71,10 @@ module Ferrum
56
71
  end
57
72
 
58
73
  def send_message(data)
74
+ if SKIP_LOGGING_SCREENSHOTS
75
+ @screenshot_commands[data[:id]] = true
76
+ end
77
+
59
78
  json = data.to_json
60
79
  @driver.text(json)
61
80
  @logger&.puts("\n\n▶ #{Ferrum.elapsed_time} #{json}")
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ferrum
4
+ class Browser
5
+ class Xvfb
6
+ NOT_FOUND = "Could not find an executable for the Xvfb. Try to install " \
7
+ "it with your package manager".freeze
8
+
9
+ def self.start(*args)
10
+ new(*args).tap(&:start)
11
+ end
12
+
13
+ def self.xvfb_path
14
+ Cliver.detect("Xvfb")
15
+ end
16
+
17
+ attr_reader :screen_size, :display_id, :pid
18
+
19
+ def initialize(options)
20
+ @path = self.class.xvfb_path
21
+ raise Cliver::Dependency::NotFound.new(NOT_FOUND) unless @path
22
+
23
+ @screen_size = options.fetch(:window_size, [1024, 768]).join("x") + "x24"
24
+ @display_id = (Time.now.to_f * 1000).to_i % 100_000_000
25
+ end
26
+
27
+ def start
28
+ @pid = ::Process.spawn("#{@path} :#{display_id} -screen 0 #{screen_size}")
29
+ ::Process.detach(@pid)
30
+ end
31
+
32
+ def to_env
33
+ { "DISPLAY" => ":#{display_id}" }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -23,6 +23,10 @@ module Ferrum
23
23
  @attributes["path"]
24
24
  end
25
25
 
26
+ def samesite
27
+ @attributes["sameSite"]
28
+ end
29
+
26
30
  def size
27
31
  @attributes["size"]
28
32
  end
@@ -65,6 +69,9 @@ module Ferrum
65
69
  cookie[:value] ||= value
66
70
  cookie[:domain] ||= default_domain
67
71
 
72
+ cookie[:httpOnly] = cookie.delete(:httponly) if cookie.key?(:httponly)
73
+ cookie[:sameSite] = cookie.delete(:samesite) if cookie.key?(:samesite)
74
+
68
75
  expires = cookie.delete(:expires).to_i
69
76
  cookie[:expires] = expires if expires > 0
70
77
 
@@ -14,11 +14,11 @@ module Ferrum
14
14
  options = { accept: true }
15
15
  response = prompt_text || default_prompt
16
16
  options.merge!(promptText: response) if response
17
- @page.command("Page.handleJavaScriptDialog", **options)
17
+ @page.command("Page.handleJavaScriptDialog", slowmoable: true, **options)
18
18
  end
19
19
 
20
20
  def dismiss
21
- @page.command("Page.handleJavaScriptDialog", accept: false)
21
+ @page.command("Page.handleJavaScriptDialog", slowmoable: true, accept: false)
22
22
  end
23
23
 
24
24
  def match?(regexp)
@@ -7,9 +7,8 @@ module Ferrum
7
7
  class Frame
8
8
  include DOM, Runtime
9
9
 
10
- attr_reader :id, :page, :parent_id, :state
11
- attr_writer :execution_id
12
- attr_accessor :name
10
+ attr_reader :page, :parent_id, :state
11
+ attr_accessor :id, :name
13
12
 
14
13
  def initialize(id, page, parent_id = nil)
15
14
  @id, @page, @parent_id = id, page, parent_id
@@ -18,8 +17,6 @@ module Ferrum
18
17
  # Can be one of:
19
18
  # * started_loading
20
19
  # * navigated
21
- # * scheduled_navigation
22
- # * cleared_scheduled_navigation
23
20
  # * stopped_loading
24
21
  def state=(value)
25
22
  @state = value
@@ -37,6 +34,19 @@ module Ferrum
37
34
  @parent_id.nil?
38
35
  end
39
36
 
37
+ def set_content(html)
38
+ evaluate_async(%(
39
+ document.open();
40
+ document.write(arguments[0]);
41
+ document.close();
42
+ arguments[1](true);
43
+ ), @page.timeout, html)
44
+ end
45
+
46
+ def execution_id?(execution_id)
47
+ @execution_id == execution_id
48
+ end
49
+
40
50
  def execution_id
41
51
  raise NoExecutionContextError unless @execution_id
42
52
  @execution_id
@@ -45,6 +55,14 @@ module Ferrum
45
55
  @page.event.wait(@page.timeout) ? retry : raise
46
56
  end
47
57
 
58
+ def set_execution_id(value)
59
+ @execution_id ||= value
60
+ end
61
+
62
+ def reset_execution_id
63
+ @execution_id = nil
64
+ end
65
+
48
66
  def inspect
49
67
  %(#<#{self.class} @id=#{@id.inspect} @parent_id=#{@parent_id.inspect} @name=#{@name.inspect} @state=#{@state.inspect} @execution_id=#{@execution_id.inspect}>)
50
68
  end
@@ -29,65 +29,62 @@ module Ferrum
29
29
  end
30
30
 
31
31
  def doctype
32
- evaluate("new XMLSerializer().serializeToString(document.doctype)")
32
+ evaluate("document.doctype && new XMLSerializer().serializeToString(document.doctype)")
33
33
  end
34
34
 
35
35
  def body
36
36
  evaluate("document.documentElement.outerHTML")
37
37
  end
38
38
 
39
- def at_xpath(selector, within: nil)
40
- xpath(selector, within: within).first
41
- end
42
-
43
- # FIXME: Check within
44
39
  def xpath(selector, within: nil)
45
- evaluate_async(%(
46
- try {
47
- let selector = arguments[0];
48
- let within = arguments[1] || document;
49
- let results = [];
40
+ code = <<~JS
41
+ let selector = arguments[0];
42
+ let within = arguments[1] || document;
43
+ let results = [];
50
44
 
51
- let xpath = document.evaluate(selector, within, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
52
- for (let i = 0; i < xpath.snapshotLength; i++) {
53
- results.push(xpath.snapshotItem(i));
54
- }
45
+ let xpath = document.evaluate(selector, within, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
46
+ for (let i = 0; i < xpath.snapshotLength; i++) {
47
+ results.push(xpath.snapshotItem(i));
48
+ }
55
49
 
56
- arguments[2](results);
57
- } catch (error) {
58
- // DOMException.INVALID_EXPRESSION_ERR is undefined, using pure code
59
- if (error.code == DOMException.SYNTAX_ERR || error.code == 51) {
60
- throw "Invalid Selector";
61
- } else {
62
- throw error;
63
- }
64
- }), @page.timeout, selector, within)
50
+ arguments[2](results);
51
+ JS
52
+
53
+ evaluate_async(code, @page.timeout, selector, within)
65
54
  end
66
55
 
67
- # FIXME css doesn't work for a frame w/o execution_id
68
- def css(selector, within: nil)
69
- node_id = within&.node_id || @page.document_id
56
+ def at_xpath(selector, within: nil)
57
+ code = <<~JS
58
+ let selector = arguments[0];
59
+ let within = arguments[1] || document;
60
+ let xpath = document.evaluate(selector, within, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
61
+ let result = xpath.snapshotItem(0);
62
+ arguments[2](result);
63
+ JS
70
64
 
71
- ids = @page.command("DOM.querySelectorAll",
72
- nodeId: node_id,
73
- selector: selector)["nodeIds"]
74
- ids.map { |id| build_node(id) }.compact
65
+ evaluate_async(code, @page.timeout, selector, within)
75
66
  end
76
67
 
77
- def at_css(selector, within: nil)
78
- node_id = within&.node_id || @page.document_id
68
+ def css(selector, within: nil)
69
+ code = <<~JS
70
+ let selector = arguments[0];
71
+ let within = arguments[1] || document;
72
+ let results = within.querySelectorAll(selector);
73
+ arguments[2](results);
74
+ JS
79
75
 
80
- id = @page.command("DOM.querySelector",
81
- nodeId: node_id,
82
- selector: selector)["nodeId"]
83
- build_node(id)
76
+ evaluate_async(code, @page.timeout, selector, within)
84
77
  end
85
78
 
86
- private
79
+ def at_css(selector, within: nil)
80
+ code = <<~JS
81
+ let selector = arguments[0];
82
+ let within = arguments[1] || document;
83
+ let result = within.querySelector(selector);
84
+ arguments[2](result);
85
+ JS
87
86
 
88
- def build_node(node_id)
89
- description = @page.command("DOM.describeNode", nodeId: node_id)
90
- Node.new(self, @page.target_id, node_id, description["node"])
87
+ evaluate_async(code, @page.timeout, selector, within)
91
88
  end
92
89
  end
93
90
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "singleton"
4
+
3
5
  module Ferrum
4
6
  class Frame
5
7
  module Runtime
@@ -85,8 +87,10 @@ module Ferrum
85
87
  options.merge!(returnByValue: by_value)
86
88
 
87
89
  response = @page.command("Runtime.callFunctionOn",
88
- wait: wait, **options)["result"]
89
- .tap { |r| handle_error(r) }
90
+ wait: wait, slowmoable: true,
91
+ **options)
92
+ handle_error(response)
93
+ response = response["result"]
90
94
 
91
95
  by_value ? response.dig("value") : handle_response(response)
92
96
  end
@@ -137,14 +141,18 @@ module Ferrum
137
141
  end
138
142
 
139
143
  response = @page.command("Runtime.callFunctionOn",
140
- **params)["result"].tap { |r| handle_error(r) }
144
+ slowmoable: true,
145
+ **params)
146
+ handle_error(response)
147
+ response = response["result"]
141
148
 
142
149
  handle ? handle_response(response) : response
143
150
  end
144
151
  end
145
152
 
146
153
  # FIXME: We should have a central place to handle all type of errors
147
- def handle_error(result)
154
+ def handle_error(response)
155
+ result = response["result"]
148
156
  return if result["subtype"] != "error"
149
157
 
150
158
  case result["description"]
@@ -209,7 +217,7 @@ module Ferrum
209
217
 
210
218
  def reduce_props(object_id, to)
211
219
  if cyclic?(object_id).dig("result", "value")
212
- return "(cyclic structure)"
220
+ return to.is_a?(Array) ? [cyclic_object] : cyclic_object
213
221
  else
214
222
  props = @page.command("Runtime.getProperties", ownProperties: true, objectId: object_id)
215
223
  props["result"].reduce(to) do |memo, prop|
@@ -220,38 +228,51 @@ module Ferrum
220
228
  end
221
229
 
222
230
  def cyclic?(object_id)
223
- @page.command("Runtime.callFunctionOn",
224
- objectId: object_id,
225
- returnByValue: true,
226
- functionDeclaration: <<~JS
227
- function() {
228
- if (Array.isArray(this) &&
229
- this.every(e => e instanceof Node)) {
230
- return false;
231
- }
231
+ @page.command(
232
+ "Runtime.callFunctionOn",
233
+ objectId: object_id,
234
+ returnByValue: true,
235
+ functionDeclaration: <<~JS
236
+ function() {
237
+ if (Array.isArray(this) &&
238
+ this.every(e => e instanceof Node)) {
239
+ return false;
240
+ }
232
241
 
233
- const seen = [];
234
- function detectCycle(obj) {
235
- if (typeof obj === 'object') {
236
- if (seen.indexOf(obj) !== -1) {
237
- return true;
238
- }
239
- seen.push(obj);
240
- for (let key in obj) {
241
- if (obj.hasOwnProperty(key) && detectCycle(obj[key])) {
242
- return true;
243
- }
244
- }
245
- }
246
-
247
- return false;
242
+ const seen = [];
243
+ function detectCycle(obj) {
244
+ if (typeof obj === "object") {
245
+ if (seen.indexOf(obj) !== -1) {
246
+ return true;
247
+ }
248
+ seen.push(obj);
249
+ for (let key in obj) {
250
+ if (obj.hasOwnProperty(key) && detectCycle(obj[key])) {
251
+ return true;
248
252
  }
249
-
250
- return detectCycle(this);
251
253
  }
252
- JS
253
- )
254
+ }
255
+
256
+ return false;
257
+ }
258
+
259
+ return detectCycle(this);
260
+ }
261
+ JS
262
+ )
254
263
  end
264
+
265
+ def cyclic_object
266
+ CyclicObject.instance
267
+ end
268
+ end
269
+ end
270
+
271
+ class CyclicObject
272
+ include Singleton
273
+
274
+ def inspect
275
+ %(#<#{self.class} JavaScript object that cannot be represented in Ruby>)
255
276
  end
256
277
  end
257
278
  end
@@ -40,7 +40,7 @@ module Ferrum
40
40
 
41
41
  def set_overrides(user_agent: nil, accept_language: nil, platform: nil)
42
42
  options = Hash.new
43
- options[:userAgent] = user_agent if user_agent
43
+ options[:userAgent] = user_agent || @page.browser.default_user_agent
44
44
  options[:acceptLanguage] = accept_language if accept_language
45
45
  options[:platform] if platform
46
46