ferrum 0.14 → 0.16

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.
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent-ruby"
4
+ require "forwardable"
5
+ require "ferrum/client/subscriber"
6
+ require "ferrum/client/web_socket"
7
+ require "ferrum/utils/thread"
8
+
9
+ module Ferrum
10
+ class SessionClient
11
+ attr_reader :client, :session_id
12
+
13
+ def self.event_name(event, session_id)
14
+ [event, session_id].compact.join("_")
15
+ end
16
+
17
+ def initialize(client, session_id)
18
+ @client = client
19
+ @session_id = session_id
20
+ end
21
+
22
+ def command(method, async: false, **params)
23
+ message = build_message(method, params)
24
+ @client.send_message(message, async: async)
25
+ end
26
+
27
+ def on(event, &block)
28
+ @client.on(event_name(event), &block)
29
+ end
30
+
31
+ def subscribed?(event)
32
+ @client.subscribed?(event_name(event))
33
+ end
34
+
35
+ def respond_to_missing?(name, include_private)
36
+ @client.respond_to?(name, include_private)
37
+ end
38
+
39
+ def method_missing(name, *args, **opts, &block)
40
+ @client.send(name, *args, **opts, &block)
41
+ end
42
+
43
+ def close
44
+ @client.subscriber.clear(session_id: session_id)
45
+ end
46
+
47
+ private
48
+
49
+ def build_message(method, params)
50
+ @client.build_message(method, params).merge(sessionId: session_id)
51
+ end
52
+
53
+ def event_name(event)
54
+ self.class.event_name(event, session_id)
55
+ end
56
+ end
57
+
58
+ class Client
59
+ extend Forwardable
60
+ delegate %i[timeout timeout=] => :options
61
+
62
+ attr_reader :ws_url, :options, :subscriber
63
+
64
+ def initialize(ws_url, options)
65
+ @command_id = 0
66
+ @ws_url = ws_url
67
+ @options = options
68
+ @pendings = Concurrent::Hash.new
69
+ @ws = WebSocket.new(ws_url, options.ws_max_receive_size, options.logger)
70
+ @subscriber = Subscriber.new
71
+
72
+ start
73
+ end
74
+
75
+ def command(method, async: false, **params)
76
+ message = build_message(method, params)
77
+ send_message(message, async: async)
78
+ end
79
+
80
+ def send_message(message, async:)
81
+ if async
82
+ @ws.send_message(message)
83
+ true
84
+ else
85
+ pending = Concurrent::IVar.new
86
+ @pendings[message[:id]] = pending
87
+ @ws.send_message(message)
88
+ data = pending.value!(timeout)
89
+ @pendings.delete(message[:id])
90
+
91
+ raise DeadBrowserError if data.nil? && @ws.messages.closed?
92
+ raise TimeoutError unless data
93
+
94
+ error, response = data.values_at("error", "result")
95
+ raise_browser_error(error) if error
96
+ response
97
+ end
98
+ end
99
+
100
+ def on(event, &block)
101
+ @subscriber.on(event, &block)
102
+ end
103
+
104
+ def subscribed?(event)
105
+ @subscriber.subscribed?(event)
106
+ end
107
+
108
+ def session(session_id)
109
+ SessionClient.new(self, session_id)
110
+ end
111
+
112
+ def close
113
+ @ws.close
114
+ # Give a thread some time to handle a tail of messages
115
+ @pendings.clear
116
+ @thread.kill unless @thread.join(1)
117
+ @subscriber.close
118
+ end
119
+
120
+ def inspect
121
+ "#<#{self.class} " \
122
+ "@command_id=#{@command_id.inspect} " \
123
+ "@pendings=#{@pendings.inspect} " \
124
+ "@ws=#{@ws.inspect}>"
125
+ end
126
+
127
+ def build_message(method, params)
128
+ { method: method, params: params }.merge(id: next_command_id)
129
+ end
130
+
131
+ private
132
+
133
+ def start
134
+ @thread = Utils::Thread.spawn do
135
+ loop do
136
+ message = @ws.messages.pop
137
+ break unless message
138
+
139
+ if message.key?("method")
140
+ @subscriber << message
141
+ else
142
+ @pendings[message["id"]]&.set(message)
143
+ end
144
+ end
145
+ end
146
+ end
147
+
148
+ def next_command_id
149
+ @command_id += 1
150
+ end
151
+
152
+ def raise_browser_error(error)
153
+ case error["message"]
154
+ # Node has disappeared while we were trying to get it
155
+ when "No node with given id found",
156
+ "Could not find node with given id",
157
+ "Inspected target navigated or closed"
158
+ raise NodeNotFoundError, error
159
+ # Context is lost, page is reloading
160
+ when "Cannot find context with specified id"
161
+ raise NoExecutionContextError, error
162
+ when "No target with given id found"
163
+ raise NoSuchPageError
164
+ when /Could not compute content quads/
165
+ raise CoordinatesNotFoundError
166
+ else
167
+ raise BrowserError, error
168
+ end
169
+ end
170
+ end
171
+ 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
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "yaml"
3
4
  require "ferrum/cookies/cookie"
4
5
 
5
6
  module Ferrum
@@ -169,10 +170,36 @@ module Ferrum
169
170
  true
170
171
  end
171
172
 
173
+ #
174
+ # Stores all cookies of current page in a file.
175
+ #
176
+ # @return [Integer]
177
+ #
178
+ # @example
179
+ # browser.cookies.store # => Integer
180
+ #
181
+ def store(path = "cookies.yml")
182
+ File.write(path, map(&:to_h).to_yaml)
183
+ end
184
+
185
+ #
186
+ # Loads all cookies from the file and sets them for current page.
187
+ #
188
+ # @return [true]
189
+ #
190
+ # @example
191
+ # browser.cookies.load # => true
192
+ #
193
+ def load(path = "cookies.yml")
194
+ cookies = YAML.load_file(path)
195
+ cookies.each { |c| set(c) }
196
+ true
197
+ end
198
+
172
199
  private
173
200
 
174
201
  def default_domain
175
- URI.parse(@page.browser.base_url).host if @page.browser.base_url
202
+ URI.parse(@page.base_url).host if @page.base_url
176
203
  end
177
204
  end
178
205
  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)
@@ -77,6 +78,13 @@ module Ferrum
77
78
  end
78
79
  end
79
80
 
81
+ class InvalidScreenshotFormatError < Error
82
+ def initialize(format)
83
+ valid_formats = Page::Screenshot::SUPPORTED_SCREENSHOT_FORMAT.join(" | ")
84
+ super("Invalid value #{format} for option `:format` (#{valid_formats})")
85
+ end
86
+ end
87
+
80
88
  class BrowserError < Error
81
89
  attr_reader :response
82
90
 
@@ -98,8 +106,7 @@ module Ferrum
98
106
 
99
107
  class NoExecutionContextError < BrowserError
100
108
  def initialize(response = nil)
101
- response ||= { "message" => "There's no context available" }
102
- super(response)
109
+ super(response || { "message" => "There's no context available" })
103
110
  end
104
111
  end
105
112
 
@@ -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
  #