ferrum 0.13 → 0.15

Sign up to get free protection for your applications and to get access to all the features.
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