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
@@ -0,0 +1,577 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "base64"
5
+
6
+ module Puppeteer
7
+ module Bidi
8
+ # HTTPRequest represents a request initiated by a page.
9
+ class HTTPRequest
10
+ module InterceptResolutionAction
11
+ ABORT = "abort"
12
+ RESPOND = "respond"
13
+ CONTINUE = "continue"
14
+ DISABLED = "disabled"
15
+ NONE = "none"
16
+ ALREADY_HANDLED = "already-handled"
17
+ end
18
+
19
+ DEFAULT_INTERCEPT_RESOLUTION_PRIORITY = 0
20
+
21
+ STATUS_TEXTS = {
22
+ "100" => "Continue",
23
+ "101" => "Switching Protocols",
24
+ "102" => "Processing",
25
+ "103" => "Early Hints",
26
+ "200" => "OK",
27
+ "201" => "Created",
28
+ "202" => "Accepted",
29
+ "203" => "Non-Authoritative Information",
30
+ "204" => "No Content",
31
+ "205" => "Reset Content",
32
+ "206" => "Partial Content",
33
+ "207" => "Multi-Status",
34
+ "208" => "Already Reported",
35
+ "226" => "IM Used",
36
+ "300" => "Multiple Choices",
37
+ "301" => "Moved Permanently",
38
+ "302" => "Found",
39
+ "303" => "See Other",
40
+ "304" => "Not Modified",
41
+ "305" => "Use Proxy",
42
+ "306" => "Switch Proxy",
43
+ "307" => "Temporary Redirect",
44
+ "308" => "Permanent Redirect",
45
+ "400" => "Bad Request",
46
+ "401" => "Unauthorized",
47
+ "402" => "Payment Required",
48
+ "403" => "Forbidden",
49
+ "404" => "Not Found",
50
+ "405" => "Method Not Allowed",
51
+ "406" => "Not Acceptable",
52
+ "407" => "Proxy Authentication Required",
53
+ "408" => "Request Timeout",
54
+ "409" => "Conflict",
55
+ "410" => "Gone",
56
+ "411" => "Length Required",
57
+ "412" => "Precondition Failed",
58
+ "413" => "Payload Too Large",
59
+ "414" => "URI Too Long",
60
+ "415" => "Unsupported Media Type",
61
+ "416" => "Range Not Satisfiable",
62
+ "417" => "Expectation Failed",
63
+ "418" => "I'm a teapot",
64
+ "421" => "Misdirected Request",
65
+ "422" => "Unprocessable Entity",
66
+ "423" => "Locked",
67
+ "424" => "Failed Dependency",
68
+ "425" => "Too Early",
69
+ "426" => "Upgrade Required",
70
+ "428" => "Precondition Required",
71
+ "429" => "Too Many Requests",
72
+ "431" => "Request Header Fields Too Large",
73
+ "451" => "Unavailable For Legal Reasons",
74
+ "500" => "Internal Server Error",
75
+ "501" => "Not Implemented",
76
+ "502" => "Bad Gateway",
77
+ "503" => "Service Unavailable",
78
+ "504" => "Gateway Timeout",
79
+ "505" => "HTTP Version Not Supported",
80
+ "506" => "Variant Also Negotiates",
81
+ "507" => "Insufficient Storage",
82
+ "508" => "Loop Detected",
83
+ "510" => "Not Extended",
84
+ "511" => "Network Authentication Required",
85
+ }.freeze
86
+
87
+ ERROR_REASONS = {
88
+ "aborted" => "Aborted",
89
+ "accessdenied" => "AccessDenied",
90
+ "addressunreachable" => "AddressUnreachable",
91
+ "blockedbyclient" => "BlockedByClient",
92
+ "blockedbyresponse" => "BlockedByResponse",
93
+ "connectionaborted" => "ConnectionAborted",
94
+ "connectionclosed" => "ConnectionClosed",
95
+ "connectionfailed" => "ConnectionFailed",
96
+ "connectionrefused" => "ConnectionRefused",
97
+ "connectionreset" => "ConnectionReset",
98
+ "internetdisconnected" => "InternetDisconnected",
99
+ "namenotresolved" => "NameNotResolved",
100
+ "timedout" => "TimedOut",
101
+ "failed" => "Failed",
102
+ }.freeze
103
+
104
+ REQUESTS = begin
105
+ ObjectSpace::WeakMap.new
106
+ rescue NameError
107
+ {}
108
+ end
109
+
110
+ # @rbs core_request: Core::Request -- Underlying BiDi request
111
+ # @rbs frame: Frame -- Owning frame
112
+ # @rbs interception_enabled: bool -- Whether interception is enabled
113
+ # @rbs redirect: HTTPRequest? -- Redirected request
114
+ # @rbs return: HTTPRequest
115
+ def self.from(core_request, frame, interception_enabled, redirect: nil)
116
+ request = new(core_request, frame, interception_enabled, redirect)
117
+ request.send(:initialize_request)
118
+ request
119
+ end
120
+
121
+ # @rbs core_request: Core::Request -- Underlying core request
122
+ # @rbs return: HTTPRequest? -- Mapped request
123
+ def self.for_core_request(core_request)
124
+ REQUESTS[core_request]
125
+ end
126
+
127
+ attr_reader :id
128
+
129
+ # @rbs core_request: Core::Request -- Underlying BiDi request
130
+ # @rbs frame: Frame -- Owning frame
131
+ # @rbs interception_enabled: bool -- Whether interception is enabled
132
+ # @rbs redirect: HTTPRequest? -- Redirected request
133
+ # @rbs return: void
134
+ def initialize(core_request, frame, interception_enabled, redirect)
135
+ @request = core_request
136
+ @frame = frame
137
+ @redirect_chain = redirect ? redirect.send(:redirect_chain_internal) : []
138
+ @response = nil
139
+ @authentication_handled = false
140
+ @from_memory_cache = false
141
+ @id = core_request.id
142
+
143
+ @interception = {
144
+ enabled: interception_enabled,
145
+ handled: false,
146
+ handlers: [],
147
+ resolution_state: { action: InterceptResolutionAction::NONE },
148
+ request_overrides: {},
149
+ response: nil,
150
+ abort_reason: nil,
151
+ }
152
+
153
+ REQUESTS[@request] = self
154
+ end
155
+
156
+ # @rbs return: String -- Request URL
157
+ def url
158
+ @request.url
159
+ end
160
+
161
+ # @rbs return: String -- HTTP method
162
+ def method
163
+ @request.method
164
+ end
165
+
166
+ # @rbs return: Hash[String, String] -- Lowercased headers
167
+ def headers
168
+ headers = {}
169
+ @request.headers.each do |header|
170
+ name = header["name"].to_s.downcase
171
+ value = header["value"]
172
+ next unless value.is_a?(Hash)
173
+ next unless value["type"] == "string"
174
+
175
+ headers[name] = value["value"]
176
+ end
177
+ headers.dup
178
+ end
179
+
180
+ # @rbs return: String? -- POST body (if available)
181
+ def post_data
182
+ @request.post_data
183
+ end
184
+
185
+ # @rbs return: bool -- Whether request has POST data
186
+ def has_post_data?
187
+ @request.has_post_data?
188
+ end
189
+
190
+ # @rbs return: String? -- POST body fetched from browser
191
+ def fetch_post_data
192
+ @request.fetch_post_data.wait
193
+ end
194
+
195
+ # @rbs return: String -- Resource type
196
+ def resource_type
197
+ (@request.resource_type || "other").downcase
198
+ end
199
+
200
+ # @rbs return: Frame -- Request initiator frame
201
+ def frame
202
+ @frame
203
+ end
204
+
205
+ # @rbs return: bool -- Whether this is a navigation request
206
+ def navigation_request?
207
+ !@request.navigation.nil?
208
+ end
209
+
210
+ # @rbs return: Array[HTTPRequest] -- Redirect chain
211
+ def redirect_chain
212
+ @redirect_chain.dup
213
+ end
214
+
215
+ # @rbs return: HTTPResponse? -- Response if available
216
+ def response
217
+ @response
218
+ end
219
+
220
+ # @rbs return: Hash[String, String]? -- Failure info
221
+ def failure
222
+ return nil if @request.error.nil?
223
+
224
+ { "errorText" => @request.error }
225
+ end
226
+
227
+ # @rbs return: Hash[String, untyped]? -- Initiator metadata
228
+ def initiator
229
+ initiator = @request.initiator
230
+ return nil unless initiator
231
+
232
+ normalized = {}
233
+ initiator.each do |key, value|
234
+ normalized[key.to_s] = value
235
+ end
236
+ normalized["type"] ||= "other"
237
+ normalized
238
+ end
239
+
240
+ # @rbs return: Hash[String, untyped] -- Timing info
241
+ def timing
242
+ @request.timing
243
+ end
244
+
245
+ # @rbs return: Hash[Symbol, untyped] -- Interception resolution state
246
+ def intercept_resolution_state
247
+ return { action: InterceptResolutionAction::DISABLED } unless @request.blocked?
248
+
249
+ if !@interception[:enabled]
250
+ return { action: InterceptResolutionAction::DISABLED }
251
+ end
252
+ if @interception[:handled]
253
+ return { action: InterceptResolutionAction::ALREADY_HANDLED }
254
+ end
255
+ @interception[:resolution_state].dup
256
+ end
257
+
258
+ # @rbs return: bool -- Whether intercept is already handled
259
+ def intercept_resolution_handled?
260
+ @interception[:handled]
261
+ end
262
+
263
+ # @rbs return: Hash[Symbol | String, untyped] -- Continue overrides
264
+ def continue_request_overrides
265
+ @interception[:request_overrides]
266
+ end
267
+
268
+ # @rbs return: Hash[Symbol | String, untyped]? -- Response overrides
269
+ def response_for_request
270
+ @interception[:response]
271
+ end
272
+
273
+ # @rbs return: String? -- Abort error reason
274
+ def abort_error_reason
275
+ @interception[:abort_reason]
276
+ end
277
+
278
+ # @rbs &block: (-> untyped) -- Intercept handler
279
+ # @rbs return: void
280
+ def enqueue_intercept_action(&block)
281
+ @interception[:handlers] << block
282
+ end
283
+
284
+ # @rbs return: void
285
+ def finalize_interceptions
286
+ @interception[:handlers].each do |handler|
287
+ AsyncUtils.await(handler.call)
288
+ end
289
+ @interception[:handlers] = []
290
+
291
+ case intercept_resolution_state[:action]
292
+ when InterceptResolutionAction::ABORT
293
+ _abort(@interception[:abort_reason])
294
+ when InterceptResolutionAction::RESPOND
295
+ raise "Response is missing for the interception" if @interception[:response].nil?
296
+
297
+ _respond(@interception[:response])
298
+ when InterceptResolutionAction::CONTINUE
299
+ _continue(@interception[:request_overrides])
300
+ end
301
+ end
302
+
303
+ # @rbs overrides: Hash[Symbol | String, untyped] -- Continue overrides
304
+ # @rbs priority: Integer? -- Cooperative intercept priority
305
+ # @rbs return: void
306
+ def continue(overrides = {}, priority = nil)
307
+ verify_interception
308
+ return unless can_be_intercepted?
309
+
310
+ if priority.nil?
311
+ return _continue(overrides)
312
+ end
313
+
314
+ @interception[:request_overrides] = overrides
315
+ if @interception[:resolution_state][:priority].nil? || priority > @interception[:resolution_state][:priority]
316
+ @interception[:resolution_state] = { action: InterceptResolutionAction::CONTINUE, priority: priority }
317
+ return
318
+ end
319
+ if priority == @interception[:resolution_state][:priority]
320
+ return if [InterceptResolutionAction::ABORT, InterceptResolutionAction::RESPOND].include?(
321
+ @interception[:resolution_state][:action],
322
+ )
323
+
324
+ @interception[:resolution_state][:action] = InterceptResolutionAction::CONTINUE
325
+ end
326
+ end
327
+
328
+ # @rbs response: Hash[Symbol | String, untyped] -- Response overrides
329
+ # @rbs priority: Integer? -- Cooperative intercept priority
330
+ # @rbs return: void
331
+ def respond(response = {}, priority = nil)
332
+ verify_interception
333
+ return unless can_be_intercepted?
334
+
335
+ if priority.nil?
336
+ return _respond(response)
337
+ end
338
+
339
+ @interception[:response] = response
340
+ if @interception[:resolution_state][:priority].nil? || priority > @interception[:resolution_state][:priority]
341
+ @interception[:resolution_state] = { action: InterceptResolutionAction::RESPOND, priority: priority }
342
+ return
343
+ end
344
+ if priority == @interception[:resolution_state][:priority]
345
+ return if @interception[:resolution_state][:action] == InterceptResolutionAction::ABORT
346
+
347
+ @interception[:resolution_state][:action] = InterceptResolutionAction::RESPOND
348
+ end
349
+ end
350
+
351
+ # @rbs error_code: String -- Abort error code
352
+ # @rbs priority: Integer? -- Cooperative intercept priority
353
+ # @rbs return: void
354
+ def abort(error_code = "failed", priority = nil)
355
+ verify_interception
356
+ return unless can_be_intercepted?
357
+
358
+ error_reason = ERROR_REASONS[error_code]
359
+ raise Error, "Unknown error code: #{error_code}" unless error_reason
360
+
361
+ if priority.nil?
362
+ return _abort(error_reason)
363
+ end
364
+
365
+ @interception[:abort_reason] = error_reason
366
+ if @interception[:resolution_state][:priority].nil? || priority >= @interception[:resolution_state][:priority]
367
+ @interception[:resolution_state] = { action: InterceptResolutionAction::ABORT, priority: priority }
368
+ end
369
+ end
370
+
371
+ # @rbs return: String -- Response body (binary string)
372
+ def get_response_content
373
+ @request.response_content.wait
374
+ end
375
+
376
+ # @rbs body: String -- Response body
377
+ # @rbs return: Hash[Symbol, untyped]
378
+ def self.get_response(body)
379
+ bytes = body.is_a?(String) ? body.dup.force_encoding("BINARY") : body
380
+ {
381
+ content_length: bytes.bytesize,
382
+ base64: Base64.strict_encode64(bytes),
383
+ }
384
+ end
385
+
386
+ private
387
+
388
+ def initialize_request
389
+ @request.on(:redirect) do |redirect_request|
390
+ http_request = HTTPRequest.from(
391
+ redirect_request,
392
+ @frame,
393
+ @interception[:enabled],
394
+ redirect: self,
395
+ )
396
+ @redirect_chain << self
397
+
398
+ redirect_request.once(:success) do
399
+ @frame.page.emit(:requestfinished, http_request)
400
+ end
401
+
402
+ redirect_request.once(:error) do
403
+ @frame.page.emit(:requestfailed, http_request)
404
+ end
405
+
406
+ Async do
407
+ http_request.finalize_interceptions
408
+ end
409
+ end
410
+
411
+ @request.once(:response) do |data|
412
+ @response = HTTPResponse.from(data, self)
413
+ end
414
+
415
+ @request.once(:success) do |data|
416
+ @response = HTTPResponse.from(data, self)
417
+ end
418
+
419
+ @request.on(:authenticate) do
420
+ handle_authentication
421
+ end
422
+
423
+ @frame.page.emit(:request, self)
424
+ end
425
+
426
+ def redirect_chain_internal
427
+ @redirect_chain
428
+ end
429
+
430
+ def verify_interception
431
+ raise Error, "Request Interception is not enabled!" unless @interception[:enabled]
432
+ raise Error, "Request is already handled!" if @interception[:handled]
433
+ end
434
+
435
+ def can_be_intercepted?
436
+ @request.blocked?
437
+ end
438
+
439
+ def _continue(overrides)
440
+ headers = self.class.bidi_headers_from_hash(value_for_key(overrides, :headers, "headers"))
441
+ @interception[:handled] = true
442
+
443
+ begin
444
+ @request.continue_request(
445
+ url: value_for_key(overrides, :url, "url"),
446
+ method: value_for_key(overrides, :method, "method"),
447
+ body: body_override(value_for_key(overrides, :postData, "postData")),
448
+ headers: headers.empty? ? nil : headers,
449
+ ).wait
450
+ rescue => error
451
+ @interception[:handled] = false
452
+ self.class.handle_interception_error(error)
453
+ end
454
+ end
455
+
456
+ def _abort(_error_reason)
457
+ @interception[:handled] = true
458
+ begin
459
+ @request.fail_request.wait
460
+ rescue => error
461
+ @interception[:handled] = false
462
+ raise error
463
+ end
464
+ end
465
+
466
+ def _respond(response)
467
+ @interception[:handled] = true
468
+
469
+ parsed_body = nil
470
+ body = value_for_key(response, :body, "body")
471
+ parsed_body = self.class.get_response(body) if body
472
+
473
+ headers = self.class.bidi_headers_from_hash(value_for_key(response, :headers, "headers"))
474
+ has_content_length = headers.any? { |header| header["name"] == "content-length" }
475
+
476
+ content_type = value_for_key(response, :contentType, "contentType")
477
+ if content_type
478
+ headers << {
479
+ "name" => "content-type",
480
+ "value" => { "type" => "string", "value" => content_type.to_s },
481
+ }
482
+ end
483
+
484
+ if parsed_body && !has_content_length
485
+ headers << {
486
+ "name" => "content-length",
487
+ "value" => { "type" => "string", "value" => parsed_body[:content_length].to_s },
488
+ }
489
+ end
490
+
491
+ status = value_for_key(response, :status, "status") || 200
492
+
493
+ begin
494
+ @request.provide_response(
495
+ status_code: status,
496
+ reason_phrase: STATUS_TEXTS[status.to_s],
497
+ headers: headers.empty? ? nil : headers,
498
+ body: parsed_body ? { type: "base64", value: parsed_body[:base64] } : nil,
499
+ ).wait
500
+ rescue => error
501
+ @interception[:handled] = false
502
+ raise error
503
+ end
504
+ end
505
+
506
+ def handle_authentication
507
+ credentials = @frame.page.credentials
508
+ if credentials && !@authentication_handled
509
+ @authentication_handled = true
510
+ @request.continue_with_auth(
511
+ action: "provideCredentials",
512
+ credentials: {
513
+ "type" => "password",
514
+ "username" => credentials[:username],
515
+ "password" => credentials[:password],
516
+ },
517
+ ).wait
518
+ else
519
+ @request.continue_with_auth(action: "cancel").wait
520
+ end
521
+ end
522
+
523
+ def body_override(post_data)
524
+ return nil if post_data.nil?
525
+
526
+ {
527
+ type: "base64",
528
+ value: Base64.strict_encode64(post_data.to_s.b),
529
+ }
530
+ end
531
+
532
+ def value_for_key(hash, symbol_key, string_key)
533
+ return nil unless hash
534
+
535
+ if hash.key?(symbol_key)
536
+ hash[symbol_key]
537
+ elsif hash.key?(string_key)
538
+ hash[string_key]
539
+ else
540
+ nil
541
+ end
542
+ end
543
+
544
+ def self.bidi_headers_from_hash(raw_headers)
545
+ headers = []
546
+ (raw_headers || {}).each do |name, value|
547
+ next if value.nil?
548
+
549
+ values = value.is_a?(Array) ? value : [value]
550
+ values.each do |header_value|
551
+ headers << {
552
+ "name" => name.to_s.downcase,
553
+ "value" => {
554
+ "type" => "string",
555
+ "value" => header_value.to_s,
556
+ },
557
+ }
558
+ end
559
+ end
560
+ headers
561
+ end
562
+
563
+ def self.handle_interception_error(error)
564
+ message = error.message.to_s
565
+ if message.include?("Invalid header") ||
566
+ message.include?("Unsafe header") ||
567
+ message.include?('Expected "header"') ||
568
+ message.include?("invalid argument")
569
+ raise error
570
+ end
571
+
572
+ warn(error.full_message) if ENV["DEBUG_BIDI_COMMAND"]
573
+ nil
574
+ end
575
+ end
576
+ end
577
+ end