ferrum 0.17.2 → 0.18.0

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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/ferrum/accessibility/ax_node.rb +108 -0
  4. data/lib/ferrum/accessibility.rb +106 -0
  5. data/lib/ferrum/browser/binary.rb +41 -0
  6. data/lib/ferrum/browser/command.rb +20 -0
  7. data/lib/ferrum/browser/options/base.rb +67 -0
  8. data/lib/ferrum/browser/options/chrome.rb +36 -1
  9. data/lib/ferrum/browser/options/firefox.rb +32 -0
  10. data/lib/ferrum/browser/options.rb +41 -2
  11. data/lib/ferrum/browser/process.rb +51 -0
  12. data/lib/ferrum/browser/xvfb.rb +24 -0
  13. data/lib/ferrum/browser.rb +32 -10
  14. data/lib/ferrum/client/subscriber.rb +58 -0
  15. data/lib/ferrum/client/web_socket.rb +62 -11
  16. data/lib/ferrum/client.rb +199 -8
  17. data/lib/ferrum/context.rb +97 -4
  18. data/lib/ferrum/contexts.rb +119 -9
  19. data/lib/ferrum/cookies/cookie.rb +6 -0
  20. data/lib/ferrum/cookies.rb +5 -0
  21. data/lib/ferrum/dialog.rb +18 -2
  22. data/lib/ferrum/downloads.rb +52 -0
  23. data/lib/ferrum/errors.rb +58 -3
  24. data/lib/ferrum/frame/dom.rb +17 -0
  25. data/lib/ferrum/frame/runtime.rb +48 -7
  26. data/lib/ferrum/frame.rb +58 -1
  27. data/lib/ferrum/headers.rb +6 -0
  28. data/lib/ferrum/interceptable.rb +62 -0
  29. data/lib/ferrum/keyboard.rb +25 -0
  30. data/lib/ferrum/mouse.rb +6 -0
  31. data/lib/ferrum/network/auth_request.rb +81 -2
  32. data/lib/ferrum/network/error.rb +15 -0
  33. data/lib/ferrum/network/exchange.rb +10 -0
  34. data/lib/ferrum/network/intercepted_request.rb +94 -2
  35. data/lib/ferrum/network/request.rb +1 -1
  36. data/lib/ferrum/network/response.rb +2 -0
  37. data/lib/ferrum/network.rb +121 -9
  38. data/lib/ferrum/node.rb +305 -15
  39. data/lib/ferrum/page/animation.rb +5 -0
  40. data/lib/ferrum/page/frames.rb +69 -7
  41. data/lib/ferrum/page/screencast.rb +5 -0
  42. data/lib/ferrum/page/screenshot.rb +52 -20
  43. data/lib/ferrum/page/stream.rb +56 -0
  44. data/lib/ferrum/page/tracing.rb +6 -0
  45. data/lib/ferrum/page.rb +139 -42
  46. data/lib/ferrum/proxy.rb +52 -2
  47. data/lib/ferrum/rgba.rb +10 -0
  48. data/lib/ferrum/target.rb +124 -1
  49. data/lib/ferrum/utils/attempt.rb +20 -0
  50. data/lib/ferrum/utils/elapsed_time.rb +38 -0
  51. data/lib/ferrum/utils/event.rb +14 -0
  52. data/lib/ferrum/utils/platform.rb +21 -0
  53. data/lib/ferrum/utils/thread.rb +12 -0
  54. data/lib/ferrum/version.rb +1 -1
  55. data/lib/ferrum/worker.rb +125 -0
  56. data/lib/ferrum.rb +7 -0
  57. metadata +6 -16
data/lib/ferrum/frame.rb CHANGED
@@ -4,6 +4,12 @@ require "ferrum/frame/dom"
4
4
  require "ferrum/frame/runtime"
5
5
 
6
6
  module Ferrum
7
+ #
8
+ # Represents a frame (the main document or a nested `iframe`) within a
9
+ # {Page}. Each frame has its own execution context for JavaScript and its
10
+ # own lifecycle state, tracked via {#state}. DOM search and JS evaluation
11
+ # methods are provided by the included {DOM} and {Runtime} modules.
12
+ #
7
13
  class Frame
8
14
  include DOM
9
15
  include Runtime
@@ -12,6 +18,7 @@ module Ferrum
12
18
  started_loading
13
19
  navigated
14
20
  stopped_loading
21
+ canceled
15
22
  ].freeze
16
23
 
17
24
  # The Frame's unique id.
@@ -36,16 +43,36 @@ module Ferrum
36
43
 
37
44
  # One of the states frame's in.
38
45
  #
39
- # @return [:started_loading, :navigated, :stopped_loading, nil]
46
+ # @return [:started_loading, :navigated, :stopped_loading, :canceled, nil]
40
47
  attr_reader :state
41
48
 
49
+ # Frame loader id.
50
+ #
51
+ # @return [String, nil]
52
+ attr_accessor :loader_id
53
+
54
+ # Frame's lifecycle events (navigation, load, paint, etc.).
55
+ #
56
+ # @return [Array<Hash{String => (String|Float)}>]
57
+ attr_reader :lifecycle_events
58
+
42
59
  def initialize(id, page, parent_id = nil)
43
60
  @id = id
44
61
  @page = page
45
62
  @parent_id = parent_id
63
+ @lifecycle_events = []
46
64
  @execution_id = Concurrent::MVar.new
47
65
  end
48
66
 
67
+ # Sets the frame's state, validating it's one of {STATE_VALUES}.
68
+ #
69
+ # @param [Symbol] value
70
+ # One of `:started_loading`, `:navigated`, `:stopped_loading`,
71
+ # `:canceled`.
72
+ #
73
+ # @return [Symbol]
74
+ #
75
+ # @raise [ArgumentError]
49
76
  def state=(value)
50
77
  raise ArgumentError unless STATE_VALUES.include?(value)
51
78
 
@@ -94,6 +121,20 @@ module Ferrum
94
121
  @parent_id.nil?
95
122
  end
96
123
 
124
+ #
125
+ # Returns whether the frame has finished loading (+:stopped_loading+ state).
126
+ # Frames in +:canceled+ state (execution context torn down mid-navigation)
127
+ # are not considered idle.
128
+ #
129
+ # @return [Boolean]
130
+ #
131
+ # @example
132
+ # browser.go_to("https://example.com")
133
+ # browser.main_frame.idle? # => true
134
+ def idle?
135
+ state == :stopped_loading
136
+ end
137
+
97
138
  #
98
139
  # Returns the parent frame if this frame is nested in another one.
99
140
  #
@@ -164,6 +205,15 @@ module Ferrum
164
205
  value
165
206
  end
166
207
 
208
+ #
209
+ # Sets the execution context id, or clears it if +nil+ is given (e.g.
210
+ # when the context is torn down mid-navigation and hasn't been
211
+ # replaced yet).
212
+ #
213
+ # @param [Integer, nil] value
214
+ #
215
+ # @return [Integer, nil]
216
+ #
167
217
  def execution_id=(value)
168
218
  if value.nil?
169
219
  @execution_id.try_take!
@@ -172,11 +222,18 @@ module Ferrum
172
222
  end
173
223
  end
174
224
 
225
+ #
226
+ # Debug representation of the frame, including its internal state.
227
+ #
228
+ # @return [String]
229
+ #
175
230
  def inspect
176
231
  "#<#{self.class} " \
177
232
  "@id=#{@id.inspect} " \
178
233
  "@parent_id=#{@parent_id.inspect} " \
179
234
  "@name=#{@name.inspect} " \
235
+ "@loader_id=#{@loader_id.inspect} " \
236
+ "@lifecycle_events=#{@lifecycle_events.inspect} " \
180
237
  "@state=#{@state.inspect} " \
181
238
  "@execution_id=#{@execution_id.inspect}>"
182
239
  end
@@ -1,6 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ferrum
4
+ #
5
+ # Manages the extra HTTP headers a page sends with its requests, via the
6
+ # CDP `Network.setExtraHTTPHeaders` command. Setting a `User-Agent` or
7
+ # `Accept-Language` header also updates the corresponding navigator
8
+ # override so JavaScript sees consistent values.
9
+ #
4
10
  class Headers
5
11
  def initialize(page)
6
12
  @page = page
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ferrum
4
+ # Shared by {Page} and {Worker}: subscribes to the CDP events behind the
5
+ # `:request` (Fetch-domain request interception) and `:auth` (proxy/basic
6
+ # auth challenges) pseudo-events, on top of the includer's own `client`
7
+ # and `network`. Anything else is passed straight through to `client`.
8
+ module Interceptable
9
+ # Subscribes to a CDP event, or to `:request`/`:auth`.
10
+ #
11
+ # @param [Symbol, String] name
12
+ #
13
+ # @return [Integer]
14
+ # The subscription id, used to unsubscribe via {#off}.
15
+ def on(name, &block)
16
+ case name
17
+ when :request
18
+ client.on("Fetch.requestPaused") do |params, index, total|
19
+ request = Network::InterceptedRequest.new(client, params)
20
+ exchange = network.find_or_build_exchange(request.network_id)
21
+ exchange.intercepted_request = request
22
+ block.call(request, index, total)
23
+ end
24
+ when :auth
25
+ client.on("Fetch.authRequired") do |params, index, total|
26
+ request = Network::AuthRequest.new(self, params)
27
+ block.call(request, index, total)
28
+ end
29
+ else
30
+ client.on(name, &block)
31
+ end
32
+ end
33
+
34
+ # Unsubscribes a listener previously registered via {#on}.
35
+ #
36
+ # @param [Symbol, String] name
37
+ #
38
+ # @param [Integer] id
39
+ # The subscription id returned by {#on}.
40
+ #
41
+ # @return [void]
42
+ def off(name, id)
43
+ case name
44
+ when :request
45
+ client.off("Fetch.requestPaused", id)
46
+ when :auth
47
+ client.off("Fetch.authRequired", id)
48
+ else
49
+ client.off(name, id)
50
+ end
51
+ end
52
+
53
+ # Whether there's at least one callback registered for the event.
54
+ #
55
+ # @param [String] event
56
+ #
57
+ # @return [Boolean]
58
+ def subscribed?(event)
59
+ client.subscribed?(event)
60
+ end
61
+ end
62
+ end
@@ -3,6 +3,12 @@
3
3
  require "json"
4
4
 
5
5
  module Ferrum
6
+ #
7
+ # Simulates keyboard input for a page via the CDP `Input.dispatchKeyEvent`
8
+ # command. Provides low-level `down`/`up` for individual keys as well as
9
+ # `type` for typing a sequence of characters/keys, translating key names
10
+ # and modifier chords into the CDP key event parameters Chrome expects.
11
+ #
6
12
  class Keyboard
7
13
  KEYS = JSON.parse(File.read(File.expand_path("keyboard.json", __dir__)))
8
14
  MODIFIERS = { "alt" => 1, "ctrl" => 2, "control" => 2,
@@ -136,6 +142,13 @@ module Ferrum
136
142
  modifiers: pressed.map { |k| MODIFIERS[k] }.reduce(0, :|)
137
143
  )
138
144
 
145
+ # Synthetic key events alone do not trigger the browser's editing
146
+ # shortcuts (e.g. select-all). When the chord matches a platform
147
+ # accelerator, name the editing command explicitly so Chrome
148
+ # executes it via the CDP `commands` field.
149
+ command = editing_command(pressed, char)
150
+ key = key.merge(commands: [command]) if command
151
+
139
152
  modifiers = pressed.map { |k| to_options(KEYS.fetch(KEYS_MAPPING[k.to_sym])) }
140
153
  modifiers + [to_options(key)]
141
154
  end.flatten
@@ -157,5 +170,17 @@ module Ferrum
157
170
  def to_options(hash)
158
171
  hash.inject({}) { |memo, (k, v)| memo.merge(k.to_sym => v) }
159
172
  end
173
+
174
+ # Names the CDP editing command for a modifier chord, or nil if the chord
175
+ # is not an editing accelerator. The accelerator modifier is Cmd on macOS
176
+ # and Ctrl elsewhere, matching Chrome's own key bindings. Currently only
177
+ # select-all is mapped; extend the guard for further commands as needed.
178
+ def editing_command(pressed, char)
179
+ return unless char.casecmp?("a")
180
+ return if pressed.include?("shift")
181
+
182
+ accelerator = Utils::Platform.mac? ? MODIFIERS["meta"] : MODIFIERS["ctrl"]
183
+ "selectAll" if pressed.map { |k| MODIFIERS[k] }.compact.uniq == [accelerator]
184
+ end
160
185
  end
161
186
  end
data/lib/ferrum/mouse.rb CHANGED
@@ -1,6 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ferrum
4
+ #
5
+ # Simulates mouse input for a page via the CDP `Input.dispatchMouseEvent`
6
+ # command, and page scrolling via JavaScript. Keeps track of the current
7
+ # pointer position and pressed buttons so `move`, `down`, `up` and `click`
8
+ # can be composed while reporting consistent state to Chrome.
9
+ #
4
10
  class Mouse
5
11
  CLICK_WAIT = ENV.fetch("FERRUM_CLICK_WAIT", 0.1).to_f
6
12
  BUTTON_MASKS = {
@@ -2,6 +2,11 @@
2
2
 
3
3
  module Ferrum
4
4
  class Network
5
+ #
6
+ # Represents an HTTP authentication challenge (basic or digest auth
7
+ # prompt) raised by the browser via `Fetch.authRequired`, which can be
8
+ # answered with credentials or canceled.
9
+ #
5
10
  class AuthRequest
6
11
  attr_accessor :request_id, :frame_id, :resource_type
7
12
 
@@ -14,47 +19,121 @@ module Ferrum
14
19
  @request = params["request"]
15
20
  end
16
21
 
22
+ #
23
+ # Whether this request is for the navigation of a frame, as opposed
24
+ # to a subresource (script, image, XHR, etc).
25
+ #
26
+ # @return [Boolean]
27
+ #
17
28
  def navigation_request?
18
29
  @params["isNavigationRequest"]
19
30
  end
20
31
 
32
+ #
33
+ # Whether the authentication challenge's source matches the given
34
+ # type.
35
+ #
36
+ # @param [String, Symbol] source
37
+ # One of `:server` or `:proxy`.
38
+ #
39
+ # @return [Boolean]
40
+ #
21
41
  def auth_challenge?(source)
22
42
  @params.dig("authChallenge", "source")&.downcase&.to_s == source.to_s
23
43
  end
24
44
 
25
- def match?(regexp)
26
- !!url.match(regexp)
45
+ #
46
+ # Whether the request's URL matches the given pattern.
47
+ #
48
+ # @param [String, Regexp] pattern
49
+ #
50
+ # @return [Boolean]
51
+ #
52
+ def match?(pattern)
53
+ case url
54
+ when pattern
55
+ true
56
+ else
57
+ false
58
+ end
27
59
  end
28
60
 
61
+ #
62
+ # Responds to the authentication challenge, e.g. with credentials or by
63
+ # canceling it.
64
+ #
65
+ # @param [Hash] options
66
+ #
67
+ # @option options [String] :response
68
+ # One of `"Default"`, `"CancelAuth"`, or `"ProvideCredentials"`.
69
+ #
70
+ # @option options [String] :username
71
+ #
72
+ # @option options [String] :password
73
+ #
29
74
  def continue(**options)
30
75
  options = options.merge(requestId: request_id)
31
76
  @page.command("Fetch.continueWithAuth", **options)
32
77
  end
33
78
 
79
+ #
80
+ # Cancels the authentication challenge and the request.
81
+ #
34
82
  def abort
35
83
  @page.command("Fetch.failRequest", requestId: request_id, errorReason: "BlockedByClient")
36
84
  end
37
85
 
86
+ #
87
+ # The URL for the request.
88
+ #
89
+ # @return [String]
90
+ #
38
91
  def url
39
92
  @request["url"]
40
93
  end
41
94
 
95
+ #
96
+ # The request method.
97
+ #
98
+ # @return [String]
99
+ #
42
100
  def method
43
101
  @request["method"]
44
102
  end
45
103
 
104
+ #
105
+ # The request headers.
106
+ #
107
+ # @return [Hash{String => String}]
108
+ #
46
109
  def headers
47
110
  @request["headers"]
48
111
  end
49
112
 
113
+ #
114
+ # The request's initial priority, one of `"VeryLow"`, `"Low"`,
115
+ # `"Medium"`, `"High"`, or `"VeryHigh"`.
116
+ #
117
+ # @return [String]
118
+ #
50
119
  def initial_priority
51
120
  @request["initialPriority"]
52
121
  end
53
122
 
123
+ #
124
+ # The request's referrer policy.
125
+ #
126
+ # @return [String]
127
+ #
54
128
  def referrer_policy
55
129
  @request["referrerPolicy"]
56
130
  end
57
131
 
132
+ #
133
+ # Inspects the auth request.
134
+ #
135
+ # @return [String]
136
+ #
58
137
  def inspect
59
138
  "#<#{self.class} " \
60
139
  "@request_id=#{@request_id.inspect} " \
@@ -2,15 +2,30 @@
2
2
 
3
3
  module Ferrum
4
4
  class Network
5
+ #
6
+ # Represents a network-level loading error reported for a request,
7
+ # e.g. via `Network.loadingFailed`. Distinct from the Ruby exceptions
8
+ # defined in `ferrum/errors.rb`.
9
+ #
5
10
  class Error
6
11
  attr_writer :canceled
7
12
  attr_reader :time, :timestamp
8
13
  attr_accessor :id, :url, :type, :error_text, :monotonic_time, :description
9
14
 
15
+ # Whether the request was canceled.
16
+ #
17
+ # @return [Boolean]
10
18
  def canceled?
11
19
  @canceled
12
20
  end
13
21
 
22
+ # Sets the error's timestamp, deriving {#time} from it.
23
+ #
24
+ # @param [Float] value
25
+ # Timestamp in milliseconds since epoch, as reported by
26
+ # `Log.entryAdded`.
27
+ #
28
+ # @return [Float]
14
29
  def timestamp=(value)
15
30
  @timestamp = value
16
31
  @time = Time.strptime((value / 1000).to_s, "%s")
@@ -2,6 +2,11 @@
2
2
 
3
3
  module Ferrum
4
4
  class Network
5
+ #
6
+ # Pairs a {Request} with its eventual {Response} or {Error} (and, if
7
+ # interception is enabled, its {InterceptedRequest}), representing the
8
+ # full lifecycle of a single network request.
9
+ #
5
10
  class Exchange
6
11
  # ID of the request.
7
12
  #
@@ -34,6 +39,11 @@ module Ferrum
34
39
  # @return Boolean
35
40
  attr_accessor :unknown
36
41
 
42
+ # The raw `Network.requestWillBeSentExtraInfo` params for the request,
43
+ # if it arrived before `request` was set.
44
+ #
45
+ # @return [Hash, nil]
46
+ #
37
47
  # @api private
38
48
  attr_accessor :request_extra_info
39
49
 
@@ -5,6 +5,11 @@ require "base64"
5
5
 
6
6
  module Ferrum
7
7
  class Network
8
+ #
9
+ # Represents a request paused by request interception (`Fetch.enable`),
10
+ # allowing it to be continued as is or with overrides, fulfilled with a
11
+ # fake response, or aborted before it reaches the network.
12
+ #
8
13
  class InterceptedRequest
9
14
  include RequestParams
10
15
 
@@ -21,18 +26,62 @@ module Ferrum
21
26
  @network_id = params["networkId"]
22
27
  end
23
28
 
29
+ #
30
+ # Whether the request's current status matches the given value.
31
+ #
32
+ # @param [String, Symbol] value
33
+ # One of `:responded`, `:continued`, `:aborted`.
34
+ #
35
+ # @return [Boolean]
36
+ #
24
37
  def status?(value)
25
38
  @status == value.to_sym
26
39
  end
27
40
 
41
+ #
42
+ # Whether this request is for the navigation of a frame, as opposed
43
+ # to a subresource (script, image, XHR, etc).
44
+ #
45
+ # @return [Boolean]
46
+ #
28
47
  def navigation_request?
29
48
  @params["isNavigationRequest"]
30
49
  end
31
50
 
32
- def match?(regexp)
33
- !!url.match(regexp)
51
+ #
52
+ # Whether the request's URL matches the given pattern.
53
+ #
54
+ # @param [String, Regexp] pattern
55
+ #
56
+ # @return [Boolean]
57
+ #
58
+ def match?(pattern)
59
+ case url
60
+ when pattern
61
+ true
62
+ else
63
+ false
64
+ end
34
65
  end
35
66
 
67
+ #
68
+ # Fulfills the intercepted request with a fake response instead of
69
+ # letting it reach the network.
70
+ #
71
+ # @param [Hash] options
72
+ #
73
+ # @option options [Integer] :responseCode
74
+ # HTTP status code to respond with, `200` by default.
75
+ #
76
+ # @option options [String] :body
77
+ # Response body.
78
+ #
79
+ # @option options [Hash] :responseHeaders
80
+ # Response headers.
81
+ #
82
+ # @example
83
+ # request.respond(body: "Lorem ipsum")
84
+ #
36
85
  def respond(**options)
37
86
  has_body = options.key?(:body)
38
87
  headers = has_body ? { "content-length" => options.fetch(:body, "").length } : {}
@@ -46,25 +95,68 @@ module Ferrum
46
95
  @client.command("Fetch.fulfillRequest", async: true, **options)
47
96
  end
48
97
 
98
+ #
99
+ # Continues the intercepted request, letting it reach the network as
100
+ # is, or with the given overrides.
101
+ #
102
+ # @param [Hash] options
103
+ #
104
+ # @option options [String] :url
105
+ # Overrides the request's URL.
106
+ #
107
+ # @option options [String] :method
108
+ # Overrides the request's method.
109
+ #
110
+ # @option options [String] :postData
111
+ # Overrides the request's post data.
112
+ #
113
+ # @option options [Hash] :headers
114
+ # Overrides the request's headers.
115
+ #
116
+ # @example
117
+ # request.continue
118
+ #
49
119
  def continue(**options)
50
120
  options = options.merge(requestId: request_id)
51
121
  @status = :continued
52
122
  @client.command("Fetch.continueRequest", async: true, **options)
53
123
  end
54
124
 
125
+ #
126
+ # Aborts the intercepted request.
127
+ #
128
+ # @example
129
+ # request.abort
130
+ #
55
131
  def abort
56
132
  @status = :aborted
57
133
  @client.command("Fetch.failRequest", async: true, requestId: request_id, errorReason: "BlockedByClient")
58
134
  end
59
135
 
136
+ #
137
+ # The request's initial priority, one of `"VeryLow"`, `"Low"`,
138
+ # `"Medium"`, `"High"`, or `"VeryHigh"`.
139
+ #
140
+ # @return [String]
141
+ #
60
142
  def initial_priority
61
143
  @request["initialPriority"]
62
144
  end
63
145
 
146
+ #
147
+ # The request's referrer policy.
148
+ #
149
+ # @return [String]
150
+ #
64
151
  def referrer_policy
65
152
  @request["referrerPolicy"]
66
153
  end
67
154
 
155
+ #
156
+ # Inspects the intercepted request.
157
+ #
158
+ # @return [String]
159
+ #
68
160
  def inspect
69
161
  "#<#{self.class} " \
70
162
  "@request_id=#{@request_id.inspect} " \
@@ -33,7 +33,7 @@ module Ferrum
33
33
  end
34
34
 
35
35
  #
36
- # The request resouce type.
36
+ # The request resource type.
37
37
  #
38
38
  # @return [String]
39
39
  #
@@ -145,6 +145,8 @@ module Ferrum
145
145
  # nop
146
146
  end
147
147
 
148
+ #
149
+ # Whether this response is the main frame's current response.
148
150
  #
149
151
  # @return [Boolean]
150
152
  #