ferrum 0.13 → 0.15

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +288 -154
  4. data/lib/ferrum/browser/command.rb +8 -0
  5. data/lib/ferrum/browser/options/chrome.rb +17 -5
  6. data/lib/ferrum/browser/options.rb +38 -25
  7. data/lib/ferrum/browser/process.rb +44 -17
  8. data/lib/ferrum/browser.rb +34 -52
  9. data/lib/ferrum/client/subscriber.rb +76 -0
  10. data/lib/ferrum/{browser → client}/web_socket.rb +36 -22
  11. data/lib/ferrum/client.rb +169 -0
  12. data/lib/ferrum/context.rb +19 -15
  13. data/lib/ferrum/contexts.rb +46 -12
  14. data/lib/ferrum/cookies/cookie.rb +57 -0
  15. data/lib/ferrum/cookies.rb +40 -4
  16. data/lib/ferrum/downloads.rb +60 -0
  17. data/lib/ferrum/errors.rb +2 -1
  18. data/lib/ferrum/frame.rb +1 -0
  19. data/lib/ferrum/headers.rb +1 -1
  20. data/lib/ferrum/network/exchange.rb +29 -2
  21. data/lib/ferrum/network/intercepted_request.rb +8 -17
  22. data/lib/ferrum/network/request.rb +23 -39
  23. data/lib/ferrum/network/request_params.rb +57 -0
  24. data/lib/ferrum/network/response.rb +25 -5
  25. data/lib/ferrum/network.rb +43 -16
  26. data/lib/ferrum/node.rb +21 -1
  27. data/lib/ferrum/page/frames.rb +5 -5
  28. data/lib/ferrum/page/screenshot.rb +42 -24
  29. data/lib/ferrum/page.rb +183 -131
  30. data/lib/ferrum/proxy.rb +1 -1
  31. data/lib/ferrum/target.rb +25 -5
  32. data/lib/ferrum/utils/elapsed_time.rb +0 -2
  33. data/lib/ferrum/utils/event.rb +19 -0
  34. data/lib/ferrum/utils/platform.rb +4 -0
  35. data/lib/ferrum/utils/thread.rb +18 -0
  36. data/lib/ferrum/version.rb +1 -1
  37. data/lib/ferrum.rb +3 -0
  38. metadata +14 -114
  39. data/lib/ferrum/browser/client.rb +0 -102
  40. data/lib/ferrum/browser/subscriber.rb +0 -36
@@ -1,15 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ferrum/network/request_params"
3
4
  require "base64"
4
5
 
5
6
  module Ferrum
6
7
  class Network
7
8
  class InterceptedRequest
9
+ include RequestParams
10
+
8
11
  attr_accessor :request_id, :frame_id, :resource_type, :network_id, :status
9
12
 
10
- def initialize(page, params)
13
+ def initialize(client, params)
11
14
  @status = nil
12
- @page = page
15
+ @client = client
13
16
  @params = params
14
17
  @request_id = params["requestId"]
15
18
  @frame_id = params["frameId"]
@@ -40,30 +43,18 @@ module Ferrum
40
43
  options = options.merge(body: Base64.strict_encode64(options.fetch(:body, ""))) if has_body
41
44
 
42
45
  @status = :responded
43
- @page.command("Fetch.fulfillRequest", **options)
46
+ @client.command("Fetch.fulfillRequest", async: true, **options)
44
47
  end
45
48
 
46
49
  def continue(**options)
47
50
  options = options.merge(requestId: request_id)
48
51
  @status = :continued
49
- @page.command("Fetch.continueRequest", **options)
52
+ @client.command("Fetch.continueRequest", async: true, **options)
50
53
  end
51
54
 
52
55
  def abort
53
56
  @status = :aborted
54
- @page.command("Fetch.failRequest", requestId: request_id, errorReason: "BlockedByClient")
55
- end
56
-
57
- def url
58
- @request["url"]
59
- end
60
-
61
- def method
62
- @request["method"]
63
- end
64
-
65
- def headers
66
- @request["headers"]
57
+ @client.command("Fetch.failRequest", async: true, requestId: request_id, errorReason: "BlockedByClient")
67
58
  end
68
59
 
69
60
  def initial_priority
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ferrum/network/request_params"
3
4
  require "time"
4
5
 
5
6
  module Ferrum
@@ -9,6 +10,8 @@ module Ferrum
9
10
  # object.
10
11
  #
11
12
  class Request
13
+ include RequestParams
14
+
12
15
  #
13
16
  # Initializes the request object.
14
17
  #
@@ -51,69 +54,50 @@ module Ferrum
51
54
  end
52
55
 
53
56
  #
54
- # The frame ID of the request.
55
- #
56
- # @return [String]
57
- #
58
- def frame_id
59
- @params["frameId"]
60
- end
61
-
62
- #
63
- # The URL for the request.
64
- #
65
- # @return [String]
66
- #
67
- def url
68
- @request["url"]
69
- end
70
-
57
+ # Determines if the request is XHR.
71
58
  #
72
- # The URL fragment for the request.
73
- #
74
- # @return [String, nil]
59
+ # @return [Boolean]
75
60
  #
76
- def url_fragment
77
- @request["urlFragment"]
61
+ def xhr?
62
+ type?("xhr")
78
63
  end
79
64
 
80
65
  #
81
- # The request method.
66
+ # The frame ID of the request.
82
67
  #
83
68
  # @return [String]
84
69
  #
85
- def method
86
- @request["method"]
70
+ def frame_id
71
+ @params["frameId"]
87
72
  end
88
73
 
89
74
  #
90
- # The request headers.
75
+ # The request timestamp.
91
76
  #
92
- # @return [Hash{String => String}]
77
+ # @return [Time]
93
78
  #
94
- def headers
95
- @request["headers"]
79
+ def time
80
+ @time ||= Time.strptime(@params["wallTime"].to_s, "%s")
96
81
  end
97
82
 
98
83
  #
99
- # The request timestamp.
84
+ # Determines if a request is of type ping.
100
85
  #
101
- # @return [Time]
86
+ # @return [Boolean]
102
87
  #
103
- def time
104
- @time ||= Time.strptime(@params["wallTime"].to_s, "%s")
88
+ def ping?
89
+ type?("ping")
105
90
  end
106
91
 
107
92
  #
108
- # The optional HTTP `POST` form data.
93
+ # Converts the request to a Hash.
109
94
  #
110
- # @return [String, nil]
111
- # The HTTP `POST` form data.
95
+ # @return [Hash{String => Object}]
96
+ # The params of the request.
112
97
  #
113
- def post_data
114
- @request["postData"]
98
+ def to_h
99
+ @params
115
100
  end
116
- alias body post_data
117
101
  end
118
102
  end
119
103
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ferrum
4
+ class Network
5
+ #
6
+ # Common methods used by both {Request} and {InterceptedRequest}.
7
+ #
8
+ module RequestParams
9
+ #
10
+ # The URL for the request.
11
+ #
12
+ # @return [String]
13
+ #
14
+ def url
15
+ @request["url"]
16
+ end
17
+
18
+ #
19
+ # The URL fragment for the request.
20
+ #
21
+ # @return [String, nil]
22
+ #
23
+ def url_fragment
24
+ @request["urlFragment"]
25
+ end
26
+
27
+ #
28
+ # The request method.
29
+ #
30
+ # @return [String]
31
+ #
32
+ def method
33
+ @request["method"]
34
+ end
35
+
36
+ #
37
+ # The request headers.
38
+ #
39
+ # @return [Hash{String => String}]
40
+ #
41
+ def headers
42
+ @request["headers"]
43
+ end
44
+
45
+ #
46
+ # The optional HTTP `POST` form data.
47
+ #
48
+ # @return [String, nil]
49
+ # The HTTP `POST` form data.
50
+ #
51
+ def post_data
52
+ @request["postData"]
53
+ end
54
+ alias body post_data
55
+ end
56
+ end
57
+ end
@@ -18,8 +18,13 @@ module Ferrum
18
18
  # @return [Hash{String => Object}]
19
19
  attr_reader :params
20
20
 
21
+ # The response is fully loaded by the browser.
21
22
  #
22
- # Initializes the respones object.
23
+ # @return [Boolean]
24
+ attr_writer :loaded
25
+
26
+ #
27
+ # Initializes the responses object.
23
28
  #
24
29
  # @param [Page] page
25
30
  # The page associated with the network response.
@@ -121,9 +126,8 @@ module Ferrum
121
126
  #
122
127
  def body
123
128
  @body ||= begin
124
- body, encoded = @page
125
- .command("Network.getResponseBody", requestId: id)
126
- .values_at("body", "base64Encoded")
129
+ body, encoded = @page.command("Network.getResponseBody", requestId: id)
130
+ .values_at("body", "base64Encoded")
127
131
  encoded ? Base64.decode64(body) : body
128
132
  end
129
133
  end
@@ -135,8 +139,22 @@ module Ferrum
135
139
  @page.network.response == self
136
140
  end
137
141
 
142
+ # The response is fully loaded by the browser or not.
143
+ #
144
+ # @return [Boolean]
145
+ def loaded?
146
+ @loaded
147
+ end
148
+
149
+ # Whether the response is a redirect.
150
+ #
151
+ # @return [Boolean]
152
+ def redirect?
153
+ params.key?("redirectResponse")
154
+ end
155
+
138
156
  #
139
- # Comapres the respones ID to another response's ID.
157
+ # Compares the response's ID to another response's ID.
140
158
  #
141
159
  # @return [Boolean]
142
160
  # Indicates whether the response has the same ID as the other response
@@ -154,6 +172,8 @@ module Ferrum
154
172
  def inspect
155
173
  %(#<#{self.class} @params=#{@params.inspect} @response=#{@response.inspect}>)
156
174
  end
175
+
176
+ alias to_h params
157
177
  end
158
178
  end
159
179
  end
@@ -11,9 +11,10 @@ module Ferrum
11
11
  class Network
12
12
  CLEAR_TYPE = %i[traffic cache].freeze
13
13
  AUTHORIZE_TYPE = %i[server proxy].freeze
14
- RESOURCE_TYPES = %w[Document Stylesheet Image Media Font Script TextTrack
15
- XHR Fetch EventSource WebSocket Manifest
16
- SignedExchange Ping CSPViolationReport Other].freeze
14
+ REQUEST_STAGES = %i[Request Response].freeze
15
+ RESOURCE_TYPES = %i[Document Stylesheet Image Media Font Script TextTrack
16
+ XHR Fetch Prefetch EventSource WebSocket Manifest
17
+ SignedExchange Ping CSPViolationReport Preflight Other].freeze
17
18
  AUTHORIZE_BLOCK_MISSING = "Block is missing, call `authorize(...) { |r| r.continue } " \
18
19
  "or subscribe to `on(:request)` events before calling it"
19
20
  AUTHORIZE_TYPE_WRONG = ":type should be in #{AUTHORIZE_TYPE}"
@@ -58,7 +59,7 @@ module Ferrum
58
59
  # browser.at_xpath("//a[text() = 'No UI changes button']").click
59
60
  # browser.network.wait_for_idle
60
61
  #
61
- 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)
62
63
  start = Utils::ElapsedTime.monotonic_time
63
64
 
64
65
  until idle?(connections)
@@ -187,11 +188,20 @@ module Ferrum
187
188
  # end
188
189
  # browser.go_to("https://google.com")
189
190
  #
190
- def intercept(pattern: "*", resource_type: nil)
191
+ def intercept(pattern: "*", resource_type: nil, request_stage: nil, handle_auth_requests: true)
191
192
  pattern = { urlPattern: pattern }
192
- pattern[:resourceType] = resource_type if resource_type && RESOURCE_TYPES.include?(resource_type.to_s)
193
193
 
194
- @page.command("Fetch.enable", handleAuthRequests: true, patterns: [pattern])
194
+ if resource_type && RESOURCE_TYPES.none?(resource_type.to_sym)
195
+ raise ArgumentError, "Unknown resource type '#{resource_type}' must be #{RESOURCE_TYPES.join(' | ')}"
196
+ end
197
+
198
+ if request_stage && REQUEST_STAGES.none?(request_stage.to_sym)
199
+ raise ArgumentError, "Unknown request stage '#{request_stage}' must be #{REQUEST_STAGES.join(' | ')}"
200
+ end
201
+
202
+ pattern[:resourceType] = resource_type if resource_type
203
+ pattern[:requestStage] = request_stage if request_stage
204
+ @page.command("Fetch.enable", patterns: [pattern], handleAuthRequests: handle_auth_requests)
195
205
  end
196
206
 
197
207
  #
@@ -323,13 +333,23 @@ module Ferrum
323
333
  #
324
334
  # @example
325
335
  # browser.network.offline_mode
326
- # browser.go_to("https://github.com/") # => Ferrum::StatusError (Request to https://github.com/ failed to reach
327
- # server, check DNS and server status)
336
+ # browser.go_to("https://github.com/")
337
+ # # => Request to https://github.com/ failed (net::ERR_INTERNET_DISCONNECTED) (Ferrum::StatusError)
328
338
  #
329
339
  def offline_mode
330
340
  emulate_network_conditions(offline: true, latency: 0, download_throughput: 0, upload_throughput: 0)
331
341
  end
332
342
 
343
+ #
344
+ # Toggles ignoring cache for each request. If true, cache will not be used.
345
+ #
346
+ # @example
347
+ # browser.network.cache(disable: true)
348
+ #
349
+ def cache(disable:)
350
+ @page.command("Network.setCacheDisabled", cacheDisabled: disable)
351
+ end
352
+
333
353
  private
334
354
 
335
355
  def subscribe_request_will_be_sent
@@ -352,6 +372,7 @@ module Ferrum
352
372
  if params["redirectResponse"]
353
373
  previous_exchange = select(request.id)[-2]
354
374
  response = Network::Response.new(@page, params)
375
+ response.loaded = true
355
376
  previous_exchange.response = response
356
377
  end
357
378
 
@@ -364,26 +385,31 @@ module Ferrum
364
385
  def subscribe_response_received
365
386
  @page.on("Network.responseReceived") do |params|
366
387
  exchange = select(params["requestId"]).last
388
+ next unless exchange
367
389
 
368
- if exchange
369
- response = Network::Response.new(@page, params)
370
- exchange.response = response
371
- end
390
+ response = Network::Response.new(@page, params)
391
+ exchange.response = response
372
392
  end
373
393
  end
374
394
 
375
395
  def subscribe_loading_finished
376
396
  @page.on("Network.loadingFinished") do |params|
377
397
  exchange = select(params["requestId"]).last
378
- exchange.response.body_size = params["encodedDataLength"] if exchange&.response
398
+ next unless exchange
399
+
400
+ if (response = exchange.response)
401
+ response.loaded = true
402
+ response.body_size = params["encodedDataLength"]
403
+ end
379
404
  end
380
405
  end
381
406
 
382
407
  def subscribe_loading_failed
383
408
  @page.on("Network.loadingFailed") do |params|
384
409
  exchange = select(params["requestId"]).last
385
- exchange.error ||= Network::Error.new
410
+ next unless exchange
386
411
 
412
+ exchange.error ||= Network::Error.new
387
413
  exchange.error.id = params["requestId"]
388
414
  exchange.error.type = params["type"]
389
415
  exchange.error.error_text = params["errorText"]
@@ -397,8 +423,9 @@ module Ferrum
397
423
  entry = params["entry"] || {}
398
424
  if entry["source"] == "network" && entry["level"] == "error"
399
425
  exchange = select(entry["networkRequestId"]).last
400
- exchange.error ||= Network::Error.new
426
+ next unless exchange
401
427
 
428
+ exchange.error ||= Network::Error.new
402
429
  exchange.error.id = entry["networkRequestId"]
403
430
  exchange.error.url = entry["url"]
404
431
  exchange.error.description = entry["text"]
data/lib/ferrum/node.rb CHANGED
@@ -88,6 +88,26 @@ module Ferrum
88
88
  raise NotImplementedError
89
89
  end
90
90
 
91
+ def scroll_into_view
92
+ tap { page.command("DOM.scrollIntoViewIfNeeded", nodeId: node_id) }
93
+ end
94
+
95
+ def in_viewport?(of: nil)
96
+ function = <<~JS
97
+ function(element, scope) {
98
+ const rect = element.getBoundingClientRect();
99
+ const [height, width] = scope
100
+ ? [scope.offsetHeight, scope.offsetWidth]
101
+ : [window.innerHeight, window.innerWidth];
102
+ return rect.top >= 0 &&
103
+ rect.left >= 0 &&
104
+ rect.bottom <= height &&
105
+ rect.right <= width;
106
+ }
107
+ JS
108
+ page.evaluate_func(function, self, of)
109
+ end
110
+
91
111
  def select_file(value)
92
112
  page.command("DOM.setFileInputFiles", slowmoable: true, nodeId: node_id, files: Array(value))
93
113
  end
@@ -208,7 +228,7 @@ module Ferrum
208
228
 
209
229
  def content_quads
210
230
  quads = page.command("DOM.getContentQuads", nodeId: node_id)["quads"]
211
- raise CoordinatesNotFoundError, "Node is either not visible or not an HTMLElement" if quads.size.zero?
231
+ raise CoordinatesNotFoundError, "Node is either not visible or not an HTMLElement" if quads.empty?
212
232
 
213
233
  quads
214
234
  end
@@ -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
@@ -5,6 +5,9 @@ require "ferrum/rgba"
5
5
  module Ferrum
6
6
  class Page
7
7
  module Screenshot
8
+ FULL_WARNING = "Ignoring :selector or :area in #screenshot since full: true was given at %s"
9
+ AREA_WARNING = "Ignoring :area in #screenshot since selector: was given at %s"
10
+
8
11
  DEFAULT_PDF_OPTIONS = {
9
12
  landscape: false,
10
13
  paper_width: 8.5,
@@ -50,6 +53,9 @@ module Ferrum
50
53
  # @option opts [String] :selector
51
54
  # CSS selector for the given element.
52
55
  #
56
+ # @option opts [Hash] :area
57
+ # x, y, width, height to screenshot an area.
58
+ #
53
59
  # @option opts [Float] :scale
54
60
  # Zoom in/out.
55
61
  #
@@ -57,19 +63,19 @@ module Ferrum
57
63
  # Sets the background color.
58
64
  #
59
65
  # @example
60
- # browser.go_to("https://google.com/")
66
+ # page.go_to("https://google.com/")
61
67
  #
62
68
  # @example Save on the disk in PNG:
63
- # browser.screenshot(path: "google.png") # => 134660
69
+ # page.screenshot(path: "google.png") # => 134660
64
70
  #
65
71
  # @example Save on the disk in JPG:
66
- # browser.screenshot(path: "google.jpg") # => 30902
72
+ # page.screenshot(path: "google.jpg") # => 30902
67
73
  #
68
74
  # @example Save to Base64 the whole page not only viewport and reduce quality:
69
- # browser.screenshot(full: true, quality: 60) # "iVBORw0KGgoAAAANS...
75
+ # page.screenshot(full: true, quality: 60) # "iVBORw0KGgoAAAANS...
70
76
  #
71
77
  # @example Save with specific background color:
72
- # browser.screenshot(background_color: Ferrum::RGBA.new(0, 0, 0, 0.0))
78
+ # page.screenshot(background_color: Ferrum::RGBA.new(0, 0, 0, 0.0))
73
79
  #
74
80
  def screenshot(**opts)
75
81
  path, encoding = common_options(**opts)
@@ -113,9 +119,9 @@ module Ferrum
113
119
  # can pass.
114
120
  #
115
121
  # @example
116
- # browser.go_to("https://google.com/")
122
+ # page.go_to("https://google.com/")
117
123
  # # Save to disk as a PDF
118
- # browser.pdf(path: "google.pdf", paper_width: 1.0, paper_height: 1.0) # => true
124
+ # page.pdf(path: "google.pdf", paper_width: 1.0, paper_height: 1.0) # => true
119
125
  #
120
126
  def pdf(**opts)
121
127
  path, encoding = common_options(**opts)
@@ -131,8 +137,8 @@ module Ferrum
131
137
  # The path to save a file on the disk.
132
138
  #
133
139
  # @example
134
- # browser.go_to("https://google.com/")
135
- # browser.mhtml(path: "google.mhtml") # => 87742
140
+ # page.go_to("https://google.com/")
141
+ # page.mhtml(path: "google.mhtml") # => 87742
136
142
  #
137
143
  def mhtml(path: nil)
138
144
  data = command("Page.captureSnapshot", format: :mhtml).fetch("data")
@@ -147,6 +153,12 @@ module Ferrum
147
153
  JS
148
154
  end
149
155
 
156
+ def device_pixel_ratio
157
+ evaluate <<~JS
158
+ window.devicePixelRatio
159
+ JS
160
+ end
161
+
150
162
  def document_size
151
163
  evaluate <<~JS
152
164
  [document.documentElement.scrollWidth,
@@ -192,7 +204,7 @@ module Ferrum
192
204
  screenshot_options.merge!(quality: quality) if quality
193
205
  screenshot_options.merge!(format: format)
194
206
 
195
- clip = area_options(options[:full], options[:selector], scale)
207
+ clip = area_options(options[:full], options[:selector], scale, options[:area])
196
208
  screenshot_options.merge!(clip: clip) if clip
197
209
 
198
210
  screenshot_options
@@ -208,29 +220,35 @@ module Ferrum
208
220
  [format, quality]
209
221
  end
210
222
 
211
- def area_options(full, selector, scale)
212
- message = "Ignoring :selector in #screenshot since full: true was given at #{caller(1..1).first}"
213
- warn(message) if full && selector
223
+ def area_options(full, selector, scale, area = nil)
224
+ warn(FULL_WARNING % caller(1..1).first) if full && (selector || area)
225
+ warn(AREA_WARNING % caller(1..1).first) if selector && area
214
226
 
215
227
  clip = if full
216
- width, height = document_size
217
- { x: 0, y: 0, width: width, height: height, scale: scale } if width.positive? && height.positive?
228
+ full_window_area || viewport_area
218
229
  elsif selector
219
- bounding_rect(selector).merge(scale: scale)
230
+ bounding_rect(selector)
231
+ elsif area
232
+ area
233
+ else
234
+ viewport_area
220
235
  end
221
236
 
222
- if scale != 1
223
- unless clip
224
- width, height = viewport_size
225
- clip = { x: 0, y: 0, width: width, height: height }
226
- end
227
-
228
- clip.merge!(scale: scale)
229
- end
237
+ clip.merge!(scale: scale)
230
238
 
231
239
  clip
232
240
  end
233
241
 
242
+ def full_window_area
243
+ width, height = document_size
244
+ { x: 0, y: 0, width: width, height: height } if width.positive? && height.positive?
245
+ end
246
+
247
+ def viewport_area
248
+ width, height = viewport_size
249
+ { x: 0, y: 0, width: width, height: height }
250
+ end
251
+
234
252
  def bounding_rect(selector)
235
253
  rect = evaluate_async(%(
236
254
  const rect = document