puppeteer-bidi 0.0.1.beta10 → 0.0.1

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +44 -0
  3. data/API_COVERAGE.md +345 -0
  4. data/CLAUDE/porting_puppeteer.md +20 -0
  5. data/CLAUDE.md +2 -1
  6. data/DEVELOPMENT.md +14 -0
  7. data/README.md +47 -415
  8. data/development/generate_api_coverage.rb +411 -0
  9. data/development/puppeteer_revision.txt +1 -0
  10. data/lib/puppeteer/bidi/browser.rb +118 -22
  11. data/lib/puppeteer/bidi/browser_context.rb +185 -2
  12. data/lib/puppeteer/bidi/connection.rb +16 -5
  13. data/lib/puppeteer/bidi/cookie_utils.rb +192 -0
  14. data/lib/puppeteer/bidi/core/browsing_context.rb +83 -40
  15. data/lib/puppeteer/bidi/core/realm.rb +6 -0
  16. data/lib/puppeteer/bidi/core/request.rb +79 -35
  17. data/lib/puppeteer/bidi/core/user_context.rb +5 -3
  18. data/lib/puppeteer/bidi/element_handle.rb +200 -8
  19. data/lib/puppeteer/bidi/errors.rb +4 -0
  20. data/lib/puppeteer/bidi/frame.rb +115 -11
  21. data/lib/puppeteer/bidi/http_request.rb +577 -0
  22. data/lib/puppeteer/bidi/http_response.rb +161 -10
  23. data/lib/puppeteer/bidi/locator.rb +792 -0
  24. data/lib/puppeteer/bidi/page.rb +859 -7
  25. data/lib/puppeteer/bidi/query_handler.rb +1 -1
  26. data/lib/puppeteer/bidi/version.rb +1 -1
  27. data/lib/puppeteer/bidi.rb +39 -6
  28. data/sig/puppeteer/bidi/browser.rbs +53 -6
  29. data/sig/puppeteer/bidi/browser_context.rbs +36 -0
  30. data/sig/puppeteer/bidi/cookie_utils.rbs +64 -0
  31. data/sig/puppeteer/bidi/core/browsing_context.rbs +16 -6
  32. data/sig/puppeteer/bidi/core/request.rbs +14 -11
  33. data/sig/puppeteer/bidi/core/user_context.rbs +2 -2
  34. data/sig/puppeteer/bidi/element_handle.rbs +28 -0
  35. data/sig/puppeteer/bidi/errors.rbs +4 -0
  36. data/sig/puppeteer/bidi/frame.rbs +17 -0
  37. data/sig/puppeteer/bidi/http_request.rbs +162 -0
  38. data/sig/puppeteer/bidi/http_response.rbs +67 -8
  39. data/sig/puppeteer/bidi/locator.rbs +267 -0
  40. data/sig/puppeteer/bidi/page.rbs +170 -0
  41. data/sig/puppeteer/bidi.rbs +15 -3
  42. metadata +12 -1
@@ -6,6 +6,26 @@ module Puppeteer
6
6
  module Core
7
7
  # Request represents a network request
8
8
  class Request < EventEmitter
9
+ DESTINATION_RESOURCE_TYPES = {
10
+ "style" => "stylesheet",
11
+ "document" => "document",
12
+ "script" => "script",
13
+ "image" => "image",
14
+ "font" => "font",
15
+ "audio" => "media",
16
+ "video" => "media",
17
+ "track" => "texttrack",
18
+ "manifest" => "manifest",
19
+ "iframe" => "document",
20
+ "frame" => "document",
21
+ "fetch" => "fetch",
22
+ "object" => "other",
23
+ "embed" => "other",
24
+ "worker" => "other",
25
+ "sharedworker" => "other",
26
+ "serviceworker" => "other",
27
+ }.freeze
28
+
9
29
  include Disposable::DisposableMixin
10
30
 
11
31
  # Create a request instance from a beforeRequestSent event
@@ -27,8 +47,8 @@ module Puppeteer
27
47
  @error = nil
28
48
  @redirect = nil
29
49
  @response = nil
30
- @response_content_promise = nil
31
- @request_body_promise = nil
50
+ @response_content_task = nil
51
+ @request_body_task = nil
32
52
  @disposables = Disposable::DisposableStack.new
33
53
  end
34
54
 
@@ -101,7 +121,13 @@ module Puppeteer
101
121
  # Get resource type (non-standard)
102
122
  # @rbs return: String? -- Resource type
103
123
  def resource_type
104
- @event.dig('request', 'goog:resourceType')
124
+ resource_type = @event.dig('request', 'goog:resourceType')
125
+ return resource_type if resource_type
126
+
127
+ destination = @event.dig('request', 'destination')
128
+ return nil unless destination
129
+
130
+ DESTINATION_RESOURCE_TYPES.fetch(destination, destination)
105
131
  end
106
132
 
107
133
  # Get POST data (non-standard)
@@ -128,7 +154,7 @@ module Puppeteer
128
154
  # @rbs headers: Array[Hash[String, untyped]]? -- Modified headers
129
155
  # @rbs cookies: Array[Hash[String, untyped]]? -- Modified cookies
130
156
  # @rbs body: Hash[String, untyped]? -- Modified body
131
- # @rbs return: untyped
157
+ # @rbs return: Async::Task[untyped]
132
158
  def continue_request(url: nil, method: nil, headers: nil, cookies: nil, body: nil)
133
159
  params = { request: id }
134
160
  params[:url] = url if url
@@ -137,12 +163,13 @@ module Puppeteer
137
163
  params[:cookies] = cookies if cookies
138
164
  params[:body] = body if body
139
165
 
140
- session.send_command('network.continueRequest', params)
166
+ session.async_send_command('network.continueRequest', params)
141
167
  end
142
168
 
143
169
  # Fail the request
170
+ # @rbs return: Async::Task[untyped]
144
171
  def fail_request
145
- session.send_command('network.failRequest', { request: id })
172
+ session.async_send_command('network.failRequest', { request: id })
146
173
  end
147
174
 
148
175
  # Provide a response for the request
@@ -150,7 +177,7 @@ module Puppeteer
150
177
  # @rbs reason_phrase: String? -- Response reason phrase
151
178
  # @rbs headers: Array[Hash[String, untyped]]? -- Response headers
152
179
  # @rbs body: Hash[String, untyped]? -- Response body
153
- # @rbs return: untyped
180
+ # @rbs return: Async::Task[untyped]
154
181
  def provide_response(status_code: nil, reason_phrase: nil, headers: nil, body: nil)
155
182
  params = { request: id }
156
183
  params[:statusCode] = status_code if status_code
@@ -158,20 +185,24 @@ module Puppeteer
158
185
  params[:headers] = headers if headers
159
186
  params[:body] = body if body
160
187
 
161
- session.send_command('network.provideResponse', params)
188
+ session.async_send_command('network.provideResponse', params)
162
189
  end
163
190
 
164
191
  # Fetch POST data for the request
165
- # @rbs return: String? -- POST data
192
+ # @rbs return: Async::Task[String?] -- POST data
166
193
  def fetch_post_data
167
- return nil unless has_post_data?
168
- return @request_body_promise if @request_body_promise
194
+ unless has_post_data?
195
+ return Async do
196
+ nil
197
+ end
198
+ end
199
+ return @request_body_task if @request_body_task
169
200
 
170
- @request_body_promise = begin
171
- result = session.send_command('network.getData', {
201
+ @request_body_task = Async do
202
+ result = session.async_send_command('network.getData', {
172
203
  dataType: 'request',
173
204
  request: id
174
- })
205
+ }).wait
175
206
 
176
207
  bytes = result['bytes']
177
208
  if bytes['type'] == 'string'
@@ -183,34 +214,36 @@ module Puppeteer
183
214
  end
184
215
 
185
216
  # Get response content
186
- # @rbs return: String -- Response content as binary string
217
+ # @rbs return: Async::Task[String] -- Response content as binary string
187
218
  def response_content
188
- return @response_content_promise if @response_content_promise
189
-
190
- @response_content_promise = begin
191
- result = session.send_command('network.getData', {
192
- dataType: 'response',
193
- request: id
194
- })
195
-
196
- bytes = result['bytes']
197
- if bytes['type'] == 'base64'
198
- [bytes['value']].pack('m0')
199
- else
200
- bytes['value']
201
- end
202
- rescue => e
203
- if e.message.include?('No resource with given identifier found')
204
- raise 'Could not load response body for this request. This might happen if the request is a preflight request.'
219
+ return @response_content_task if @response_content_task
220
+
221
+ @response_content_task = Async do
222
+ begin
223
+ result = session.async_send_command('network.getData', {
224
+ dataType: 'response',
225
+ request: id
226
+ }).wait
227
+
228
+ bytes = result['bytes']
229
+ if bytes['type'] == 'base64'
230
+ [bytes['value']].pack('m0')
231
+ else
232
+ bytes['value']
233
+ end
234
+ rescue => e
235
+ if e.message.include?('No resource with given identifier found')
236
+ raise 'Could not load response body for this request. This might happen if the request is a preflight request.'
237
+ end
238
+ raise
205
239
  end
206
- raise
207
240
  end
208
241
  end
209
242
 
210
243
  # Continue with authentication
211
244
  # @rbs action: String -- 'provideCredentials', 'default', or 'cancel'
212
245
  # @rbs credentials: Hash[String, untyped]? -- Credentials hash with username and password
213
- # @rbs return: untyped
246
+ # @rbs return: Async::Task[untyped]
214
247
  def continue_with_auth(action:, credentials: nil)
215
248
  params = {
216
249
  request: id,
@@ -218,7 +251,7 @@ module Puppeteer
218
251
  }
219
252
  params[:credentials] = credentials if action == 'provideCredentials'
220
253
 
221
- session.send_command('network.continueWithAuth', params)
254
+ session.async_send_command('network.continueWithAuth', params)
222
255
  end
223
256
 
224
257
  protected
@@ -283,6 +316,17 @@ module Puppeteer
283
316
  dispose
284
317
  end
285
318
 
319
+ # Listen for response started
320
+ session.on('network.responseStarted') do |event|
321
+ next unless event['context'] == @browsing_context.id
322
+ next unless event.dig('request', 'request') == id
323
+ next unless event['redirectCount'] == @event['redirectCount']
324
+
325
+ @response = event['response']
326
+ @event['request']['timings'] = event.dig('request', 'timings')
327
+ emit(:response, @response)
328
+ end
329
+
286
330
  # Listen for response completed
287
331
  session.on('network.responseCompleted') do |event|
288
332
  next unless event['context'] == @browsing_context.id
@@ -97,7 +97,7 @@ module Puppeteer
97
97
 
98
98
  # Get cookies for this user context
99
99
  # @rbs source_origin: String? -- Source origin
100
- # @rbs return: Array[Hash[String, untyped]] -- Cookies
100
+ # @rbs return: Async::Task[Array[Hash[String, untyped]]] -- Cookies
101
101
  def get_cookies(**options)
102
102
  raise UserContextClosedError, @reason if closed?
103
103
 
@@ -109,8 +109,10 @@ module Puppeteer
109
109
  }
110
110
  params[:partition][:sourceOrigin] = source_origin if source_origin
111
111
 
112
- result = session.async_send_command('storage.getCookies', params)
113
- result['cookies']
112
+ Async do
113
+ result = session.async_send_command('storage.getCookies', params).wait
114
+ result['cookies']
115
+ end
114
116
  end
115
117
 
116
118
  # Set a cookie in this user context
@@ -240,6 +240,65 @@ module Puppeteer
240
240
  evaluate('element => element.focus()')
241
241
  end
242
242
 
243
+ # Select options on a <select> element
244
+ # Triggers 'change' and 'input' events once all options are selected.
245
+ # If not a select element, throws an error.
246
+ # @rbs *values: String -- Option values to select
247
+ # @rbs return: Array[String] -- Actually selected option values
248
+ def select(*values)
249
+ assert_not_disposed
250
+
251
+ # Validate all values are strings
252
+ values.each_with_index do |value, index|
253
+ unless value.is_a?(String)
254
+ raise ArgumentError, "Values must be strings. Found value of type #{value.class} at index #{index}"
255
+ end
256
+ end
257
+
258
+ # Use isolated realm to avoid user modifications to global objects (like Event).
259
+ realm = frame.isolated_realm
260
+ adopted_element = realm.adopt_handle(self)
261
+
262
+ begin
263
+ adopted_element.evaluate(<<~JS, values)
264
+ (element, vals) => {
265
+ const values = new Set(vals);
266
+ if (!(element instanceof HTMLSelectElement)) {
267
+ throw new Error('Element is not a <select> element.');
268
+ }
269
+
270
+ const selectedValues = new Set();
271
+ if (!element.multiple) {
272
+ for (const option of element.options) {
273
+ option.selected = false;
274
+ }
275
+ for (const option of element.options) {
276
+ if (values.has(option.value)) {
277
+ option.selected = true;
278
+ selectedValues.add(option.value);
279
+ break;
280
+ }
281
+ }
282
+ } else {
283
+ for (const option of element.options) {
284
+ option.selected = values.has(option.value);
285
+ if (option.selected) {
286
+ selectedValues.add(option.value);
287
+ }
288
+ }
289
+ }
290
+
291
+ element.dispatchEvent(new Event('input', {bubbles: true}));
292
+ element.dispatchEvent(new Event('change', {bubbles: true}));
293
+
294
+ return Array.from(selectedValues.values());
295
+ }
296
+ JS
297
+ ensure
298
+ adopted_element&.dispose
299
+ end
300
+ end
301
+
243
302
  # Hover over the element
244
303
  # Scrolls element into view if needed and moves mouse to element center
245
304
  # @rbs return: void
@@ -299,6 +358,70 @@ module Puppeteer
299
358
  evaluate('element => element.scrollIntoView({block: "center", inline: "center", behavior: "instant"})')
300
359
  end
301
360
 
361
+ # Create a locator based on this element handle.
362
+ # @rbs return: Locator -- Locator instance
363
+ def as_locator
364
+ assert_not_disposed
365
+
366
+ NodeLocator.create_from_handle(frame, self)
367
+ end
368
+
369
+ # Take a screenshot of the element.
370
+ # Following Puppeteer's implementation: ElementHandle.screenshot
371
+ # @rbs path: String? -- File path to save screenshot
372
+ # @rbs type: String -- Image type ('png' or 'jpeg')
373
+ # @rbs clip: Hash[Symbol, Numeric]? -- Clip region relative to element
374
+ # @rbs scroll_into_view: bool -- Scroll element into view before screenshot
375
+ # @rbs return: String -- Base64-encoded image data
376
+ def screenshot(path: nil, type: 'png', clip: nil, scroll_into_view: true)
377
+ assert_not_disposed
378
+
379
+ page = frame.page
380
+
381
+ # Scroll into view if needed
382
+ scroll_into_view_if_needed if scroll_into_view
383
+
384
+ # Get element's bounding box - must not be empty
385
+ # Note: bounding_box returns viewport-relative coordinates from getBoundingClientRect()
386
+ element_box = non_empty_visible_bounding_box
387
+
388
+ # Get page scroll offset from visualViewport to convert to document coordinates
389
+ scroll_offset = evaluate(<<~JS)
390
+ () => {
391
+ if (!window.visualViewport) {
392
+ throw new Error('window.visualViewport is not supported.');
393
+ }
394
+ return {
395
+ pageLeft: window.visualViewport.pageLeft,
396
+ pageTop: window.visualViewport.pageTop
397
+ };
398
+ }
399
+ JS
400
+
401
+ # Build element clip in document coordinates (viewport coords + scroll offset)
402
+ # Following Puppeteer's implementation: elementClip.x += pageLeft; elementClip.y += pageTop
403
+ element_clip = {
404
+ x: element_box.x + scroll_offset['pageLeft'],
405
+ y: element_box.y + scroll_offset['pageTop'],
406
+ width: element_box.width,
407
+ height: element_box.height
408
+ }
409
+
410
+ # Apply user-specified clip if provided
411
+ if clip
412
+ element_clip[:x] += clip[:x]
413
+ element_clip[:y] += clip[:y]
414
+ element_clip[:width] = clip[:width]
415
+ element_clip[:height] = clip[:height]
416
+ end
417
+
418
+ page.screenshot(
419
+ path: path,
420
+ type: type,
421
+ clip: element_clip
422
+ )
423
+ end
424
+
302
425
  # Check if element is intersecting the viewport
303
426
  # @rbs threshold: Numeric -- Intersection ratio threshold
304
427
  # @rbs return: bool -- Whether element intersects viewport
@@ -353,6 +476,10 @@ module Puppeteer
353
476
  if (!(element instanceof Element)) {
354
477
  return null;
355
478
  }
479
+ // Element is not visible
480
+ if (element.getClientRects().length === 0) {
481
+ return null;
482
+ }
356
483
  const rect = element.getBoundingClientRect();
357
484
  return {x: rect.x, y: rect.y, width: rect.width, height: rect.height};
358
485
  }
@@ -360,12 +487,12 @@ module Puppeteer
360
487
 
361
488
  return nil unless result
362
489
 
363
- # Return nil if element has zero dimensions (not visible)
364
- return nil if result['width'].zero? && result['height'].zero?
490
+ offset = top_left_corner_of_frame
491
+ return nil unless offset
365
492
 
366
493
  BoundingBox.new(
367
- x: result['x'],
368
- y: result['y'],
494
+ x: result['x'] + offset[:x],
495
+ y: result['y'] + offset[:y],
369
496
  width: result['width'],
370
497
  height: result['height']
371
498
  )
@@ -450,12 +577,23 @@ module Puppeteer
450
577
 
451
578
  return nil unless model
452
579
 
580
+ offset = top_left_corner_of_frame
581
+ return nil unless offset
582
+
453
583
  # Convert raw arrays to Point objects for each quad
454
584
  BoxModel.new(
455
- content: model['content'].map { |p| Point.new(x: p['x'], y: p['y']) },
456
- padding: model['padding'].map { |p| Point.new(x: p['x'], y: p['y']) },
457
- border: model['border'].map { |p| Point.new(x: p['x'], y: p['y']) },
458
- margin: model['margin'].map { |p| Point.new(x: p['x'], y: p['y']) },
585
+ content: model['content'].map do |p|
586
+ Point.new(x: p['x'] + offset[:x], y: p['y'] + offset[:y])
587
+ end,
588
+ padding: model['padding'].map do |p|
589
+ Point.new(x: p['x'] + offset[:x], y: p['y'] + offset[:y])
590
+ end,
591
+ border: model['border'].map do |p|
592
+ Point.new(x: p['x'] + offset[:x], y: p['y'] + offset[:y])
593
+ end,
594
+ margin: model['margin'].map do |p|
595
+ Point.new(x: p['x'] + offset[:x], y: p['y'] + offset[:y])
596
+ end,
459
597
  width: model['width'],
460
598
  height: model['height']
461
599
  )
@@ -591,6 +729,60 @@ module Puppeteer
591
729
  JS
592
730
  end
593
731
 
732
+ # Get bounding box ensuring it's non-empty and visible
733
+ # @rbs return: BoundingBox -- Non-empty bounding box
734
+ def non_empty_visible_bounding_box
735
+ box = bounding_box
736
+ raise 'Node is either not visible or not an HTMLElement' unless box
737
+ raise 'Node has 0 width.' if box.width.zero?
738
+ raise 'Node has 0 height.' if box.height.zero?
739
+
740
+ box
741
+ end
742
+
743
+ # Get top-left corner offset of the element's frame relative to the main frame
744
+ # @rbs return: Hash[Symbol, Numeric]? -- Offset hash or nil if not visible
745
+ def top_left_corner_of_frame
746
+ point = { x: 0, y: 0 }
747
+ current_frame = frame
748
+
749
+ while (parent_frame = current_frame.parent_frame)
750
+ handle = current_frame.frame_element
751
+ raise 'Unsupported frame type' unless handle
752
+
753
+ begin
754
+ parent_box = handle.evaluate(<<~JS)
755
+ element => {
756
+ // Element is not visible.
757
+ if (element.getClientRects().length === 0) {
758
+ return null;
759
+ }
760
+ const rect = element.getBoundingClientRect();
761
+ const style = window.getComputedStyle(element);
762
+ return {
763
+ left: rect.left +
764
+ parseInt(style.paddingLeft, 10) +
765
+ parseInt(style.borderLeftWidth, 10),
766
+ top: rect.top +
767
+ parseInt(style.paddingTop, 10) +
768
+ parseInt(style.borderTopWidth, 10)
769
+ };
770
+ }
771
+ JS
772
+ ensure
773
+ handle.dispose unless handle.disposed?
774
+ end
775
+
776
+ return nil unless parent_box
777
+
778
+ point[:x] += parent_box['left']
779
+ point[:y] += parent_box['top']
780
+ current_frame = parent_frame
781
+ end
782
+
783
+ point
784
+ end
785
+
594
786
  # String representation includes element type
595
787
  # @rbs return: String -- String representation
596
788
  def to_s
@@ -39,5 +39,9 @@ module Puppeteer
39
39
  # Raised when a timeout occurs (e.g., navigation timeout)
40
40
  class TimeoutError < Error
41
41
  end
42
+
43
+ # Raised when an operation is unsupported in this environment
44
+ class UnsupportedOperationError < Error
45
+ end
42
46
  end
43
47
  end
@@ -117,6 +117,24 @@ module Puppeteer
117
117
  end
118
118
  end
119
119
 
120
+ # Create a locator for a selector or function.
121
+ # @rbs selector: String? -- Selector to locate
122
+ # @rbs function: String? -- JavaScript function for function locator
123
+ # @rbs return: Locator -- Locator instance
124
+ def locator(selector = nil, function: nil)
125
+ assert_not_detached
126
+
127
+ if function
128
+ raise ArgumentError, "selector and function cannot both be set" if selector
129
+
130
+ FunctionLocator.create(self, function)
131
+ elsif selector
132
+ NodeLocator.create(self, selector)
133
+ else
134
+ raise ArgumentError, "selector or function must be provided"
135
+ end
136
+ end
137
+
120
138
  # Evaluate a function on the first element matching the selector
121
139
  # @rbs selector: String -- Selector to query
122
140
  # @rbs page_function: String -- JavaScript function to evaluate
@@ -199,6 +217,24 @@ module Puppeteer
199
217
  end
200
218
  end
201
219
 
220
+ # Select options on a <select> element matching the selector
221
+ # Triggers 'change' and 'input' events once all options are selected.
222
+ # @rbs selector: String -- Selector for <select> element
223
+ # @rbs *values: String -- Option values to select
224
+ # @rbs return: Array[String] -- Actually selected option values
225
+ def select(selector, *values)
226
+ assert_not_detached
227
+
228
+ handle = query_selector(selector)
229
+ raise SelectorNotFoundError, selector unless handle
230
+
231
+ begin
232
+ handle.select(*values)
233
+ ensure
234
+ handle.dispose
235
+ end
236
+ end
237
+
202
238
  # Get the frame URL
203
239
  # @rbs return: String -- Current URL
204
240
  def url
@@ -214,10 +250,7 @@ module Puppeteer
214
250
  response = wait_for_navigation(timeout: timeout, wait_until: wait_until) do
215
251
  @browsing_context.navigate(url, wait: 'interactive').wait
216
252
  end
217
- # Return HTTPResponse with the final URL
218
- # Note: Currently we don't track HTTP status codes from BiDi protocol
219
- # Assuming successful navigation (200 OK)
220
- HTTPResponse.new(url: @browsing_context.url, status: 200)
253
+ response
221
254
  end
222
255
 
223
256
  # Set frame content
@@ -367,6 +400,12 @@ module Puppeteer
367
400
  # Track navigation type for response creation
368
401
  navigation_type = nil # :full_page, :fragment, or :history
369
402
  navigation_obj = nil # The navigation object we're waiting for
403
+ load_listener_registered = false
404
+
405
+ # Define load_listener upfront to satisfy type checker
406
+ load_listener = proc do
407
+ promise.resolve(:full_page) unless promise.resolved?
408
+ end
370
409
 
371
410
  # Helper to set up navigation listeners
372
411
  setup_navigation_listeners = proc do |navigation|
@@ -389,8 +428,9 @@ module Puppeteer
389
428
  end
390
429
 
391
430
  # Also listen for load/domcontentloaded events to complete navigation
392
- @browsing_context.once(load_event) do
393
- promise.resolve(:full_page) unless promise.resolved?
431
+ unless load_listener_registered
432
+ @browsing_context.once(load_event, &load_listener)
433
+ load_listener_registered = true
394
434
  end
395
435
  end
396
436
 
@@ -461,11 +501,9 @@ module Puppeteer
461
501
  end
462
502
 
463
503
  # Return HTTPResponse for full page navigation, nil for fragment/history
464
- if result == :full_page
465
- HTTPResponse.new(url: @browsing_context.url, status: 200)
466
- else
467
- nil
468
- end
504
+ return nil unless result == :full_page
505
+
506
+ navigation_response_for(navigation_obj)
469
507
  rescue Async::TimeoutError
470
508
  raise Puppeteer::Bidi::TimeoutError, "Navigation timeout of #{timeout}ms exceeded"
471
509
  ensure
@@ -474,6 +512,7 @@ module Puppeteer
474
512
  @browsing_context.off(:history_updated, &history_listener)
475
513
  @browsing_context.off(:fragment_navigated, &fragment_listener)
476
514
  @browsing_context.off(:closed, &closed_listener)
515
+ @browsing_context.off(load_event, &load_listener) if load_listener_registered
477
516
  end
478
517
  end
479
518
 
@@ -556,6 +595,26 @@ module Puppeteer
556
595
  @browsing_context.on(:fragment_navigated) do
557
596
  page.emit(:framenavigated, self)
558
597
  end
598
+
599
+ @browsing_context.on(:request) do |data|
600
+ request = data[:request]
601
+ http_request = HTTPRequest.from(
602
+ request,
603
+ self,
604
+ page.network_interception_enabled?
605
+ )
606
+
607
+ request.once(:success) do
608
+ page.emit(:requestfinished, http_request)
609
+ end
610
+
611
+ request.once(:error) do
612
+ page.emit(:requestfailed, http_request)
613
+ end
614
+ page.request_interception_semaphore.async do
615
+ http_request.finalize_interceptions
616
+ end
617
+ end
559
618
  end
560
619
 
561
620
  # Create a Frame for a child browsing context
@@ -592,6 +651,51 @@ module Puppeteer
592
651
  raise FrameDetachedError, "Attempted to use detached Frame '#{_id}'." if @browsing_context.closed?
593
652
  end
594
653
 
654
+ def navigation_response_for(navigation)
655
+ return nil unless navigation&.request
656
+
657
+ request = navigation.request
658
+ resolved_request = request.last_redirect || request
659
+ http_request = HTTPRequest.for_core_request(resolved_request)
660
+ return http_request.response if http_request&.response
661
+
662
+ wait_for_request_completion(request)
663
+
664
+ resolved_request = request.last_redirect || request
665
+ http_request = HTTPRequest.for_core_request(resolved_request)
666
+ http_request&.response
667
+ end
668
+
669
+ def wait_for_request_completion(request)
670
+ loop do
671
+ return if request.response || request.error
672
+
673
+ promise = Async::Promise.new
674
+ success_listener = proc do
675
+ promise.resolve(:done) unless promise.resolved?
676
+ end
677
+ error_listener = proc do
678
+ promise.resolve(:done) unless promise.resolved?
679
+ end
680
+ redirect_listener = proc do |redirect_request|
681
+ promise.resolve(redirect_request) unless promise.resolved?
682
+ end
683
+
684
+ request.on(:success, &success_listener)
685
+ request.on(:error, &error_listener)
686
+ request.on(:redirect, &redirect_listener)
687
+
688
+ result = promise.wait
689
+ request.off(:success, &success_listener)
690
+ request.off(:error, &error_listener)
691
+ request.off(:redirect, &redirect_listener)
692
+
693
+ return if result == :done
694
+
695
+ request = result
696
+ end
697
+ end
698
+
595
699
  end
596
700
  end
597
701
  end