ferrum 0.6 → 0.9

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.
@@ -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