ferrum 0.14 → 0.15

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+ require "ferrum/client/subscriber"
5
+ require "ferrum/client/web_socket"
6
+
7
+ module Ferrum
8
+ class SessionClient
9
+ attr_reader :client, :session_id
10
+
11
+ def self.event_name(event, session_id)
12
+ [event, session_id].compact.join("_")
13
+ end
14
+
15
+ def initialize(client, session_id)
16
+ @client = client
17
+ @session_id = session_id
18
+ end
19
+
20
+ def command(method, async: false, **params)
21
+ message = build_message(method, params)
22
+ @client.send_message(message, async: async)
23
+ end
24
+
25
+ def on(event, &block)
26
+ @client.on(event_name(event), &block)
27
+ end
28
+
29
+ def subscribed?(event)
30
+ @client.subscribed?(event_name(event))
31
+ end
32
+
33
+ def respond_to_missing?(name, include_private)
34
+ @client.respond_to?(name, include_private)
35
+ end
36
+
37
+ def method_missing(name, ...)
38
+ @client.send(name, ...)
39
+ end
40
+
41
+ def close
42
+ @client.subscriber.clear(session_id: session_id)
43
+ end
44
+
45
+ private
46
+
47
+ def build_message(method, params)
48
+ @client.build_message(method, params).merge(sessionId: session_id)
49
+ end
50
+
51
+ def event_name(event)
52
+ self.class.event_name(event, session_id)
53
+ end
54
+ end
55
+
56
+ class Client
57
+ extend Forwardable
58
+ delegate %i[timeout timeout=] => :options
59
+
60
+ attr_reader :ws_url, :options, :subscriber
61
+
62
+ def initialize(ws_url, options)
63
+ @command_id = 0
64
+ @ws_url = ws_url
65
+ @options = options
66
+ @pendings = Concurrent::Hash.new
67
+ @ws = WebSocket.new(ws_url, options.ws_max_receive_size, options.logger)
68
+ @subscriber = Subscriber.new
69
+
70
+ start
71
+ end
72
+
73
+ def command(method, async: false, **params)
74
+ message = build_message(method, params)
75
+ send_message(message, async: async)
76
+ end
77
+
78
+ def send_message(message, async:)
79
+ if async
80
+ @ws.send_message(message)
81
+ true
82
+ else
83
+ pending = Concurrent::IVar.new
84
+ @pendings[message[:id]] = pending
85
+ @ws.send_message(message)
86
+ data = pending.value!(timeout)
87
+ @pendings.delete(message[:id])
88
+
89
+ raise DeadBrowserError if data.nil? && @ws.messages.closed?
90
+ raise TimeoutError unless data
91
+
92
+ error, response = data.values_at("error", "result")
93
+ raise_browser_error(error) if error
94
+ response
95
+ end
96
+ end
97
+
98
+ def on(event, &block)
99
+ @subscriber.on(event, &block)
100
+ end
101
+
102
+ def subscribed?(event)
103
+ @subscriber.subscribed?(event)
104
+ end
105
+
106
+ def session(session_id)
107
+ SessionClient.new(self, session_id)
108
+ end
109
+
110
+ def close
111
+ @ws.close
112
+ # Give a thread some time to handle a tail of messages
113
+ @pendings.clear
114
+ @thread.kill unless @thread.join(1)
115
+ @subscriber.close
116
+ end
117
+
118
+ def inspect
119
+ "#<#{self.class} " \
120
+ "@command_id=#{@command_id.inspect} " \
121
+ "@pendings=#{@pendings.inspect} " \
122
+ "@ws=#{@ws.inspect}>"
123
+ end
124
+
125
+ def build_message(method, params)
126
+ { method: method, params: params }.merge(id: next_command_id)
127
+ end
128
+
129
+ private
130
+
131
+ def start
132
+ @thread = Utils::Thread.spawn do
133
+ loop do
134
+ message = @ws.messages.pop
135
+ break unless message
136
+
137
+ if message.key?("method")
138
+ @subscriber << message
139
+ else
140
+ @pendings[message["id"]]&.set(message)
141
+ end
142
+ end
143
+ end
144
+ end
145
+
146
+ def next_command_id
147
+ @command_id += 1
148
+ end
149
+
150
+ def raise_browser_error(error)
151
+ case error["message"]
152
+ # Node has disappeared while we were trying to get it
153
+ when "No node with given id found",
154
+ "Could not find node with given id",
155
+ "Inspected target navigated or closed"
156
+ raise NodeNotFoundError, error
157
+ # Context is lost, page is reloading
158
+ when "Cannot find context with specified id"
159
+ raise NoExecutionContextError, error
160
+ when "No target with given id found"
161
+ raise NoSuchPageError
162
+ when /Could not compute content quads/
163
+ raise CoordinatesNotFoundError
164
+ else
165
+ raise BrowserError, error
166
+ end
167
+ end
168
+ end
169
+ end
@@ -8,9 +8,9 @@ module Ferrum
8
8
 
9
9
  attr_reader :id, :targets
10
10
 
11
- def initialize(browser, contexts, id)
11
+ def initialize(client, contexts, id)
12
12
  @id = id
13
- @browser = browser
13
+ @client = client
14
14
  @contexts = contexts
15
15
  @targets = Concurrent::Map.new
16
16
  @pendings = Concurrent::MVar.new
@@ -46,33 +46,37 @@ module Ferrum
46
46
  end
47
47
 
48
48
  def create_target
49
- @browser.command("Target.createTarget",
50
- browserContextId: @id,
51
- url: "about:blank")
52
- target = @pendings.take(@browser.timeout)
49
+ @client.command("Target.createTarget", browserContextId: @id, url: "about:blank")
50
+ target = @pendings.take(@client.timeout)
53
51
  raise NoSuchTargetError unless target.is_a?(Target)
54
52
 
55
- @targets.put_if_absent(target.id, target)
56
53
  target
57
54
  end
58
55
 
59
- def add_target(params)
60
- target = Target.new(@browser, params)
61
- if target.window?
62
- @targets.put_if_absent(target.id, target)
63
- else
64
- @pendings.put(target, @browser.timeout)
65
- end
56
+ def add_target(params:, session_id: nil)
57
+ new_target = Target.new(@client, session_id, params)
58
+ target = @targets.put_if_absent(new_target.id, new_target)
59
+ target ||= new_target # `put_if_absent` returns nil if added a new value or existing if there was one already
60
+ @pendings.put(target, @client.timeout) if @pendings.empty?
61
+ target
66
62
  end
67
63
 
68
64
  def update_target(target_id, params)
69
- @targets[target_id].update(params)
65
+ @targets[target_id]&.update(params)
70
66
  end
71
67
 
72
68
  def delete_target(target_id)
73
69
  @targets.delete(target_id)
74
70
  end
75
71
 
72
+ def close_targets_connection
73
+ @targets.each_value do |target|
74
+ next unless target.connected?
75
+
76
+ target.page.close_connection
77
+ end
78
+ end
79
+
76
80
  def dispose
77
81
  @contexts.dispose(@id)
78
82
  end
@@ -4,12 +4,15 @@ require "ferrum/context"
4
4
 
5
5
  module Ferrum
6
6
  class Contexts
7
+ include Enumerable
8
+
7
9
  attr_reader :contexts
8
10
 
9
- def initialize(browser)
11
+ def initialize(client)
10
12
  @contexts = Concurrent::Map.new
11
- @browser = browser
13
+ @client = client
12
14
  subscribe
15
+ auto_attach
13
16
  discover
14
17
  end
15
18
 
@@ -17,6 +20,16 @@ module Ferrum
17
20
  @default_context ||= create
18
21
  end
19
22
 
23
+ def each(&block)
24
+ return enum_for(__method__) unless block_given?
25
+
26
+ @contexts.each(&block)
27
+ end
28
+
29
+ def [](id)
30
+ @contexts[id]
31
+ end
32
+
20
33
  def find_by(target_id:)
21
34
  context = nil
22
35
  @contexts.each_value { |c| context = c if c.target?(target_id) }
@@ -24,21 +37,25 @@ module Ferrum
24
37
  end
25
38
 
26
39
  def create(**options)
27
- response = @browser.command("Target.createBrowserContext", **options)
40
+ response = @client.command("Target.createBrowserContext", **options)
28
41
  context_id = response["browserContextId"]
29
- context = Context.new(@browser, self, context_id)
42
+ context = Context.new(@client, self, context_id)
30
43
  @contexts[context_id] = context
31
44
  context
32
45
  end
33
46
 
34
47
  def dispose(context_id)
35
48
  context = @contexts[context_id]
36
- @browser.command("Target.disposeBrowserContext",
37
- browserContextId: context.id)
49
+ context.close_targets_connection
50
+ @client.command("Target.disposeBrowserContext", browserContextId: context.id)
38
51
  @contexts.delete(context_id)
39
52
  true
40
53
  end
41
54
 
55
+ def close_connections
56
+ @contexts.each_value(&:close_targets_connection)
57
+ end
58
+
42
59
  def reset
43
60
  @default_context = nil
44
61
  @contexts.each_key { |id| dispose(id) }
@@ -51,15 +68,26 @@ module Ferrum
51
68
  private
52
69
 
53
70
  def subscribe
54
- @browser.client.on("Target.targetCreated") do |params|
71
+ @client.on("Target.attachedToTarget") do |params|
72
+ info, session_id = params.values_at("targetInfo", "sessionId")
73
+ next unless info["type"] == "page"
74
+
75
+ context_id = info["browserContextId"]
76
+ @contexts[context_id]&.add_target(session_id: session_id, params: info)
77
+ if params["waitingForDebugger"]
78
+ @client.session(session_id).command("Runtime.runIfWaitingForDebugger", async: true)
79
+ end
80
+ end
81
+
82
+ @client.on("Target.targetCreated") do |params|
55
83
  info = params["targetInfo"]
56
84
  next unless info["type"] == "page"
57
85
 
58
86
  context_id = info["browserContextId"]
59
- @contexts[context_id]&.add_target(info)
87
+ @contexts[context_id]&.add_target(params: info)
60
88
  end
61
89
 
62
- @browser.client.on("Target.targetInfoChanged") do |params|
90
+ @client.on("Target.targetInfoChanged") do |params|
63
91
  info = params["targetInfo"]
64
92
  next unless info["type"] == "page"
65
93
 
@@ -67,19 +95,25 @@ module Ferrum
67
95
  @contexts[context_id]&.update_target(target_id, info)
68
96
  end
69
97
 
70
- @browser.client.on("Target.targetDestroyed") do |params|
98
+ @client.on("Target.targetDestroyed") do |params|
71
99
  context = find_by(target_id: params["targetId"])
72
100
  context&.delete_target(params["targetId"])
73
101
  end
74
102
 
75
- @browser.client.on("Target.targetCrashed") do |params|
103
+ @client.on("Target.targetCrashed") do |params|
76
104
  context = find_by(target_id: params["targetId"])
77
105
  context&.delete_target(params["targetId"])
78
106
  end
79
107
  end
80
108
 
81
109
  def discover
82
- @browser.command("Target.setDiscoverTargets", discover: true)
110
+ @client.command("Target.setDiscoverTargets", discover: true)
111
+ end
112
+
113
+ def auto_attach
114
+ return unless @client.options.flatten
115
+
116
+ @client.command("Target.setAutoAttach", autoAttach: true, waitForDebuggerOnStart: true, flatten: true)
83
117
  end
84
118
  end
85
119
  end
@@ -172,7 +172,7 @@ module Ferrum
172
172
  private
173
173
 
174
174
  def default_domain
175
- URI.parse(@page.browser.base_url).host if @page.browser.base_url
175
+ URI.parse(@page.base_url).host if @page.base_url
176
176
  end
177
177
  end
178
178
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ferrum
4
+ class Downloads
5
+ VALID_BEHAVIOR = %i[deny allow allowAndName default].freeze
6
+
7
+ def initialize(page)
8
+ @page = page
9
+ @event = Utils::Event.new.tap(&:set)
10
+ @files = {}
11
+ end
12
+
13
+ def files
14
+ @files.values
15
+ end
16
+
17
+ def wait(timeout = 5)
18
+ @event.reset
19
+ yield if block_given?
20
+ @event.wait(timeout)
21
+ @event.set
22
+ end
23
+
24
+ def set_behavior(save_path:, behavior: :allow)
25
+ raise ArgumentError unless VALID_BEHAVIOR.include?(behavior.to_sym)
26
+ raise Error, "supply absolute path for `:save_path` option" unless Pathname.new(save_path.to_s).absolute?
27
+
28
+ @page.command("Browser.setDownloadBehavior",
29
+ browserContextId: @page.context_id,
30
+ downloadPath: save_path,
31
+ behavior: behavior,
32
+ eventsEnabled: true)
33
+ end
34
+
35
+ def subscribe
36
+ subscribe_download_will_begin
37
+ subscribe_download_progress
38
+ end
39
+
40
+ def subscribe_download_will_begin
41
+ @page.on("Browser.downloadWillBegin") do |params|
42
+ @event.reset
43
+ @files[params["guid"]] = params
44
+ end
45
+ end
46
+
47
+ def subscribe_download_progress
48
+ @page.on("Browser.downloadProgress") do |params|
49
+ @files[params["guid"]].merge!(params)
50
+
51
+ case params["state"]
52
+ when "completed", "canceled"
53
+ @event.set
54
+ else
55
+ @event.reset
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
data/lib/ferrum/errors.rb CHANGED
@@ -6,7 +6,8 @@ module Ferrum
6
6
  class NoSuchTargetError < Error; end
7
7
  class NotImplementedError < Error; end
8
8
  class BinaryNotFoundError < Error; end
9
- class EmptyPathError < Error; end
9
+ class EmptyPathError < Error; end
10
+ class ServerError < Error; end
10
11
 
11
12
  class StatusError < Error
12
13
  def initialize(url, message = nil)
@@ -68,7 +68,7 @@ module Ferrum
68
68
 
69
69
  def set_overrides(user_agent: nil, accept_language: nil, platform: nil)
70
70
  options = {}
71
- options[:userAgent] = user_agent || @page.browser.default_user_agent
71
+ options[:userAgent] = user_agent || @page.default_user_agent
72
72
  options[:acceptLanguage] = accept_language if accept_language
73
73
  options[:platform] if platform
74
74
 
@@ -79,7 +79,7 @@ module Ferrum
79
79
  # @return [Boolean]
80
80
  #
81
81
  def finished?
82
- blocked? || response&.loaded? || !error.nil?
82
+ blocked? || response&.loaded? || !error.nil? || ping?
83
83
  end
84
84
 
85
85
  #
@@ -118,6 +118,15 @@ module Ferrum
118
118
  response&.redirect?
119
119
  end
120
120
 
121
+ #
122
+ # Determines if the exchange is ping.
123
+ #
124
+ # @return [Boolean]
125
+ #
126
+ def ping?
127
+ !!request&.ping?
128
+ end
129
+
121
130
  #
122
131
  # Returns request's URL.
123
132
  #
@@ -10,9 +10,9 @@ module Ferrum
10
10
 
11
11
  attr_accessor :request_id, :frame_id, :resource_type, :network_id, :status
12
12
 
13
- def initialize(page, params)
13
+ def initialize(client, params)
14
14
  @status = nil
15
- @page = page
15
+ @client = client
16
16
  @params = params
17
17
  @request_id = params["requestId"]
18
18
  @frame_id = params["frameId"]
@@ -43,18 +43,18 @@ module Ferrum
43
43
  options = options.merge(body: Base64.strict_encode64(options.fetch(:body, ""))) if has_body
44
44
 
45
45
  @status = :responded
46
- @page.command("Fetch.fulfillRequest", **options)
46
+ @client.command("Fetch.fulfillRequest", async: true, **options)
47
47
  end
48
48
 
49
49
  def continue(**options)
50
50
  options = options.merge(requestId: request_id)
51
51
  @status = :continued
52
- @page.command("Fetch.continueRequest", **options)
52
+ @client.command("Fetch.continueRequest", async: true, **options)
53
53
  end
54
54
 
55
55
  def abort
56
56
  @status = :aborted
57
- @page.command("Fetch.failRequest", requestId: request_id, errorReason: "BlockedByClient")
57
+ @client.command("Fetch.failRequest", async: true, requestId: request_id, errorReason: "BlockedByClient")
58
58
  end
59
59
 
60
60
  def initial_priority
@@ -80,6 +80,15 @@ module Ferrum
80
80
  @time ||= Time.strptime(@params["wallTime"].to_s, "%s")
81
81
  end
82
82
 
83
+ #
84
+ # Determines if a request is of type ping.
85
+ #
86
+ # @return [Boolean]
87
+ #
88
+ def ping?
89
+ type?("ping")
90
+ end
91
+
83
92
  #
84
93
  # Converts the request to a Hash.
85
94
  #
@@ -59,7 +59,7 @@ module Ferrum
59
59
  # browser.at_xpath("//a[text() = 'No UI changes button']").click
60
60
  # browser.network.wait_for_idle
61
61
  #
62
- def wait_for_idle(connections: 0, duration: 0.05, timeout: @page.browser.timeout)
62
+ def wait_for_idle(connections: 0, duration: 0.05, timeout: @page.timeout)
63
63
  start = Utils::ElapsedTime.monotonic_time
64
64
 
65
65
  until idle?(connections)
@@ -385,19 +385,19 @@ module Ferrum
385
385
  def subscribe_response_received
386
386
  @page.on("Network.responseReceived") do |params|
387
387
  exchange = select(params["requestId"]).last
388
+ next unless exchange
388
389
 
389
- if exchange
390
- response = Network::Response.new(@page, params)
391
- exchange.response = response
392
- end
390
+ response = Network::Response.new(@page, params)
391
+ exchange.response = response
393
392
  end
394
393
  end
395
394
 
396
395
  def subscribe_loading_finished
397
396
  @page.on("Network.loadingFinished") do |params|
398
- response = select(params["requestId"]).last&.response
397
+ exchange = select(params["requestId"]).last
398
+ next unless exchange
399
399
 
400
- if response
400
+ if (response = exchange.response)
401
401
  response.loaded = true
402
402
  response.body_size = params["encodedDataLength"]
403
403
  end
@@ -407,8 +407,9 @@ module Ferrum
407
407
  def subscribe_loading_failed
408
408
  @page.on("Network.loadingFailed") do |params|
409
409
  exchange = select(params["requestId"]).last
410
- exchange.error ||= Network::Error.new
410
+ next unless exchange
411
411
 
412
+ exchange.error ||= Network::Error.new
412
413
  exchange.error.id = params["requestId"]
413
414
  exchange.error.type = params["type"]
414
415
  exchange.error.error_text = params["errorText"]
@@ -422,8 +423,9 @@ module Ferrum
422
423
  entry = params["entry"] || {}
423
424
  if entry["source"] == "network" && entry["level"] == "error"
424
425
  exchange = select(entry["networkRequestId"]).last
425
- exchange.error ||= Network::Error.new
426
+ next unless exchange
426
427
 
428
+ exchange.error ||= Network::Error.new
427
429
  exchange.error.id = entry["networkRequestId"]
428
430
  exchange.error.url = entry["url"]
429
431
  exchange.error.description = entry["text"]
@@ -16,8 +16,8 @@ module Ferrum
16
16
  # @return [Array<Frame>]
17
17
  #
18
18
  # @example
19
- # browser.go_to("https://www.w3schools.com/tags/tag_frame.asp")
20
- # browser.frames # =>
19
+ # page.go_to("https://www.w3schools.com/tags/tag_frame.asp")
20
+ # page.frames # =>
21
21
  # # [
22
22
  # # #<Ferrum::Frame
23
23
  # # @id="C6D104CE454A025FBCF22B98DE612B12"
@@ -39,7 +39,7 @@ module Ferrum
39
39
  # Find frame by given options.
40
40
  #
41
41
  # @param [String] id
42
- # Unique frame's id that browser provides.
42
+ # Unique frame's id that page provides.
43
43
  #
44
44
  # @param [String] name
45
45
  # Frame's name if there's one.
@@ -51,7 +51,7 @@ module Ferrum
51
51
  # The matching frame.
52
52
  #
53
53
  # @example
54
- # browser.frame_by(id: "C6D104CE454A025FBCF22B98DE612B12")
54
+ # page.frame_by(id: "C6D104CE454A025FBCF22B98DE612B12")
55
55
  #
56
56
  def frame_by(id: nil, name: nil, execution_id: nil)
57
57
  if id
@@ -134,7 +134,7 @@ module Ferrum
134
134
  end
135
135
 
136
136
  frame = @frames[params["frameId"]]
137
- frame.state = :stopped_loading
137
+ frame&.state = :stopped_loading
138
138
 
139
139
  @event.set if idling?
140
140
  end