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.
@@ -13,7 +13,7 @@ module Ferrum
13
13
 
14
14
  def initialize
15
15
  super
16
- @on = Hash.new { |h, k| h[k] = [] }
16
+ @on = Concurrent::Hash.new { |h, k| h[k] = Concurrent::Array.new }
17
17
  end
18
18
 
19
19
  def on(event, &block)
@@ -21,6 +21,10 @@ module Ferrum
21
21
  true
22
22
  end
23
23
 
24
+ def subscribed?(event)
25
+ @on.key?(event)
26
+ end
27
+
24
28
  def call(message)
25
29
  method, params = message.values_at("method", "params")
26
30
  total = @on[method].size
@@ -8,6 +8,7 @@ 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
 
@@ -20,6 +21,10 @@ module Ferrum
20
21
  @driver = ::WebSocket::Driver.client(self, max_length: max_receive_size)
21
22
  @messages = Queue.new
22
23
 
24
+ if SKIP_LOGGING_SCREENSHOTS
25
+ @screenshot_commands = Concurrent::Hash.new
26
+ end
27
+
23
28
  @driver.on(:open, &method(:on_open))
24
29
  @driver.on(:message, &method(:on_message))
25
30
  @driver.on(:close, &method(:on_close))
@@ -50,7 +55,14 @@ module Ferrum
50
55
  def on_message(event)
51
56
  data = JSON.parse(event.data)
52
57
  @messages.push(data)
53
- @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")
54
66
  end
55
67
 
56
68
  def on_close(_event)
@@ -59,6 +71,10 @@ module Ferrum
59
71
  end
60
72
 
61
73
  def send_message(data)
74
+ if SKIP_LOGGING_SCREENSHOTS
75
+ @screenshot_commands[data[:id]] = true
76
+ end
77
+
62
78
  json = data.to_json
63
79
  @driver.text(json)
64
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
@@ -10,7 +10,7 @@ module Ferrum
10
10
 
11
11
  def initialize(browser, contexts, id)
12
12
  @browser, @contexts, @id = browser, contexts, id
13
- @targets = Concurrent::Hash.new
13
+ @targets = Concurrent::Map.new
14
14
  @pendings = Concurrent::MVar.new
15
15
  end
16
16
 
@@ -42,19 +42,19 @@ module Ferrum
42
42
  end
43
43
 
44
44
  def create_target
45
- target_id = @browser.command("Target.createTarget",
46
- browserContextId: @id,
47
- url: "about:blank")["targetId"]
45
+ @browser.command("Target.createTarget",
46
+ browserContextId: @id,
47
+ url: "about:blank")
48
48
  target = @pendings.take(@browser.timeout)
49
49
  raise NoSuchTargetError unless target.is_a?(Target)
50
- @targets[target.id] = target
50
+ @targets.put_if_absent(target.id, target)
51
51
  target
52
52
  end
53
53
 
54
54
  def add_target(params)
55
55
  target = Target.new(@browser, params)
56
56
  if target.window?
57
- @targets[target.id] = target
57
+ @targets.put_if_absent(target.id, target)
58
58
  else
59
59
  @pendings.put(target, @browser.timeout)
60
60
  end
@@ -72,6 +72,10 @@ module Ferrum
72
72
  @contexts.dispose(@id)
73
73
  end
74
74
 
75
+ def has_target?(target_id)
76
+ !!@targets[target_id]
77
+ end
78
+
75
79
  def inspect
76
80
  %(#<#{self.class} @id=#{@id.inspect} @targets=#{@targets.inspect} @default_target=#{@default_target.inspect}>)
77
81
  end
@@ -7,7 +7,7 @@ module Ferrum
7
7
  attr_reader :contexts
8
8
 
9
9
  def initialize(browser)
10
- @contexts = Concurrent::Hash.new
10
+ @contexts = Concurrent::Map.new
11
11
  @browser = browser
12
12
  subscribe
13
13
  discover
@@ -18,7 +18,9 @@ module Ferrum
18
18
  end
19
19
 
20
20
  def find_by(target_id:)
21
- @contexts.find { |_, c| c.targets.keys.include?(target_id) }&.last
21
+ context = nil
22
+ @contexts.each_value { |c| context = c if c.has_target?(target_id) }
23
+ context
22
24
  end
23
25
 
24
26
  def create
data/lib/ferrum/frame.rb CHANGED
@@ -11,6 +11,7 @@ module Ferrum
11
11
  attr_accessor :id, :name
12
12
 
13
13
  def initialize(id, page, parent_id = nil)
14
+ @execution_id = nil
14
15
  @id, @page, @parent_id = id, page, parent_id
15
16
  end
16
17
 
@@ -37,54 +37,54 @@ module Ferrum
37
37
  end
38
38
 
39
39
  def xpath(selector, within: nil)
40
- code = <<~JS
41
- let selector = arguments[0];
42
- let within = arguments[1] || document;
43
- let results = [];
40
+ expr = <<~JS
41
+ function(selector, within) {
42
+ let results = [];
43
+ within ||= document
44
44
 
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
- }
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
+ }
49
49
 
50
- arguments[2](results);
50
+ return results;
51
+ }
51
52
  JS
52
53
 
53
- evaluate_async(code, @page.timeout, selector, within)
54
+ evaluate_func(expr, selector, within)
54
55
  end
55
56
 
56
57
  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);
58
+ expr = <<~JS
59
+ function(selector, within) {
60
+ within ||= document
61
+ let xpath = document.evaluate(selector, within, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
62
+ return xpath.snapshotItem(0);
63
+ }
63
64
  JS
64
-
65
- evaluate_async(code, @page.timeout, selector, within)
65
+ evaluate_func(expr, selector, within)
66
66
  end
67
67
 
68
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);
69
+ expr = <<~JS
70
+ function(selector, within) {
71
+ within ||= document
72
+ return Array.from(within.querySelectorAll(selector));
73
+ }
74
74
  JS
75
75
 
76
- evaluate_async(code, @page.timeout, selector, within)
76
+ evaluate_func(expr, selector, within)
77
77
  end
78
78
 
79
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);
80
+ expr = <<~JS
81
+ function(selector, within) {
82
+ within ||= document
83
+ return within.querySelector(selector);
84
+ }
85
85
  JS
86
86
 
87
- evaluate_async(code, @page.timeout, selector, within)
87
+ evaluate_func(expr, selector, within)
88
88
  end
89
89
  end
90
90
  end
@@ -1,36 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "singleton"
4
+
3
5
  module Ferrum
6
+ class CyclicObject
7
+ include Singleton
8
+
9
+ def inspect
10
+ %(#<#{self.class} JavaScript object that cannot be represented in Ruby>)
11
+ end
12
+ end
13
+
4
14
  class Frame
5
15
  module Runtime
6
16
  INTERMITTENT_ATTEMPTS = ENV.fetch("FERRUM_INTERMITTENT_ATTEMPTS", 6).to_i
7
17
  INTERMITTENT_SLEEP = ENV.fetch("FERRUM_INTERMITTENT_SLEEP", 0.1).to_f
8
18
 
9
- EXECUTE_OPTIONS = {
10
- returnByValue: true,
11
- functionDeclaration: %(function() { %s })
12
- }.freeze
13
- DEFAULT_OPTIONS = {
14
- functionDeclaration: %(function() { return %s })
15
- }.freeze
16
- EVALUATE_ASYNC_OPTIONS = {
17
- awaitPromise: true,
18
- functionDeclaration: %(
19
- function() {
20
- return new Promise((__resolve, __reject) => {
21
- try {
22
- arguments[arguments.length] = r => __resolve(r);
23
- arguments.length = arguments.length + 1;
24
- setTimeout(() => __reject(new Error("timed out promise")), %s);
25
- %s
26
- } catch(error) {
27
- __reject(error);
28
- }
29
- });
30
- }
31
- )
32
- }.freeze
33
-
34
19
  SCRIPT_SRC_TAG = <<~JS
35
20
  const script = document.createElement("script");
36
21
  script.src = arguments[0];
@@ -61,37 +46,45 @@ module Ferrum
61
46
  JS
62
47
 
63
48
  def evaluate(expression, *args)
64
- call(*args, expression: expression)
49
+ expression = "function() { return %s }" % expression
50
+ call(expression: expression, arguments: args)
65
51
  end
66
52
 
67
- def evaluate_async(expression, wait_time, *args)
68
- call(*args, expression: expression, wait_time: wait_time * 1000, **EVALUATE_ASYNC_OPTIONS)
53
+ def evaluate_async(expression, wait, *args)
54
+ template = <<~JS
55
+ function() {
56
+ return new Promise((__f, __r) => {
57
+ try {
58
+ arguments[arguments.length] = r => __f(r);
59
+ arguments.length = arguments.length + 1;
60
+ setTimeout(() => __r(new Error("timed out promise")), %s);
61
+ %s
62
+ } catch(error) {
63
+ __r(error);
64
+ }
65
+ });
66
+ }
67
+ JS
68
+
69
+ expression = template % [wait * 1000, expression]
70
+ call(expression: expression, arguments: args, awaitPromise: true)
69
71
  end
70
72
 
71
73
  def execute(expression, *args)
72
- call(*args, expression: expression, handle: false, **EXECUTE_OPTIONS)
74
+ expression = "function() { %s }" % expression
75
+ call(expression: expression, arguments: args, handle: false, returnByValue: true)
73
76
  true
74
77
  end
75
78
 
76
- def evaluate_on(node:, expression:, by_value: true, wait: 0)
77
- errors = [NodeNotFoundError, NoExecutionContextError]
78
- attempts, sleep = INTERMITTENT_ATTEMPTS, INTERMITTENT_SLEEP
79
-
80
- Ferrum.with_attempts(errors: errors, max: attempts, wait: sleep) do
81
- response = @page.command("DOM.resolveNode", nodeId: node.node_id)
82
- object_id = response.dig("object", "objectId")
83
- options = DEFAULT_OPTIONS.merge(objectId: object_id)
84
- options[:functionDeclaration] = options[:functionDeclaration] % expression
85
- options.merge!(returnByValue: by_value)
86
-
87
- response = @page.command("Runtime.callFunctionOn",
88
- wait: wait, slowmoable: true,
89
- **options)
90
- handle_error(response)
91
- response = response["result"]
79
+ def evaluate_func(expression, *args, on: nil)
80
+ call(expression: expression, arguments: args, on: on)
81
+ end
92
82
 
93
- by_value ? response.dig("value") : handle_response(response)
94
- end
83
+ def evaluate_on(node:, expression:, by_value: true, wait: 0)
84
+ options = { handle: true }
85
+ expression = "function() { return %s }" % expression
86
+ options = { handle: false, returnByValue: true } if by_value
87
+ call(expression: expression, on: node, wait: wait, **options)
95
88
  end
96
89
 
97
90
  def add_script_tag(url: nil, path: nil, content: nil, type: "text/javascript")
@@ -124,27 +117,31 @@ module Ferrum
124
117
 
125
118
  private
126
119
 
127
- def call(*args, expression:, wait_time: nil, handle: true, **options)
120
+ def call(expression:, arguments: [], on: nil, wait: 0, handle: true, **options)
128
121
  errors = [NodeNotFoundError, NoExecutionContextError]
129
122
  attempts, sleep = INTERMITTENT_ATTEMPTS, INTERMITTENT_SLEEP
130
123
 
131
124
  Ferrum.with_attempts(errors: errors, max: attempts, wait: sleep) do
132
- arguments = prepare_args(args)
133
- params = DEFAULT_OPTIONS.merge(options)
134
- expression = [wait_time, expression] if wait_time
135
- params[:functionDeclaration] = params[:functionDeclaration] % expression
136
- params = params.merge(arguments: arguments)
137
- unless params[:executionContextId]
125
+ params = options.dup
126
+
127
+ if on
128
+ response = @page.command("DOM.resolveNode", nodeId: on.node_id)
129
+ object_id = response.dig("object", "objectId")
130
+ params = params.merge(objectId: object_id)
131
+ end
132
+
133
+ if params[:executionContextId].nil? && params[:objectId].nil?
138
134
  params = params.merge(executionContextId: execution_id)
139
135
  end
140
136
 
141
137
  response = @page.command("Runtime.callFunctionOn",
142
- slowmoable: true,
143
- **params)
138
+ wait: wait, slowmoable: true,
139
+ **params.merge(functionDeclaration: expression,
140
+ arguments: prepare_args(arguments)))
144
141
  handle_error(response)
145
142
  response = response["result"]
146
143
 
147
- handle ? handle_response(response) : response
144
+ handle ? handle_response(response) : response.dig("value")
148
145
  end
149
146
  end
150
147
 
@@ -215,7 +212,7 @@ module Ferrum
215
212
 
216
213
  def reduce_props(object_id, to)
217
214
  if cyclic?(object_id).dig("result", "value")
218
- return "(cyclic structure)"
215
+ return to.is_a?(Array) ? [cyclic_object] : cyclic_object
219
216
  else
220
217
  props = @page.command("Runtime.getProperties", ownProperties: true, objectId: object_id)
221
218
  props["result"].reduce(to) do |memo, prop|
@@ -237,15 +234,13 @@ module Ferrum
237
234
  return false;
238
235
  }
239
236
 
240
- const seen = [];
241
- function detectCycle(obj) {
237
+ function detectCycle(obj, seen) {
242
238
  if (typeof obj === "object") {
243
239
  if (seen.indexOf(obj) !== -1) {
244
240
  return true;
245
241
  }
246
- seen.push(obj);
247
242
  for (let key in obj) {
248
- if (obj.hasOwnProperty(key) && detectCycle(obj[key])) {
243
+ if (obj.hasOwnProperty(key) && detectCycle(obj[key], seen.concat([obj]))) {
249
244
  return true;
250
245
  }
251
246
  }
@@ -254,11 +249,15 @@ module Ferrum
254
249
  return false;
255
250
  }
256
251
 
257
- return detectCycle(this);
252
+ return detectCycle(this, []);
258
253
  }
259
254
  JS
260
255
  )
261
256
  end
257
+
258
+ def cyclic_object
259
+ CyclicObject.instance
260
+ end
262
261
  end
263
262
  end
264
263
  end