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
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
- require 'base64'
5
- require 'fileutils'
4
+ require "base64"
5
+ require "fileutils"
6
+ require "uri"
7
+ require "async/semaphore"
6
8
 
7
9
  module Puppeteer
8
10
  module Bidi
@@ -10,6 +12,58 @@ module Puppeteer
10
12
  # This is a high-level wrapper around Core::BrowsingContext
11
13
  class Page
12
14
  DEFAULT_TIMEOUT = 30_000 #: Integer
15
+ UNIT_TO_PIXELS = {
16
+ "px" => 1,
17
+ "in" => 96,
18
+ "cm" => 37.8,
19
+ "mm" => 3.78
20
+ }.freeze
21
+ PAPER_FORMATS = {
22
+ "letter" => {
23
+ "cm" => { width: 21.59, height: 27.94 },
24
+ "in" => { width: 8.5, height: 11 }
25
+ },
26
+ "legal" => {
27
+ "cm" => { width: 21.59, height: 35.56 },
28
+ "in" => { width: 8.5, height: 14 }
29
+ },
30
+ "tabloid" => {
31
+ "cm" => { width: 27.94, height: 43.18 },
32
+ "in" => { width: 11, height: 17 }
33
+ },
34
+ "ledger" => {
35
+ "cm" => { width: 43.18, height: 27.94 },
36
+ "in" => { width: 17, height: 11 }
37
+ },
38
+ "a0" => {
39
+ "cm" => { width: 84.1, height: 118.9 },
40
+ "in" => { width: 33.1102, height: 46.811 }
41
+ },
42
+ "a1" => {
43
+ "cm" => { width: 59.4, height: 84.1 },
44
+ "in" => { width: 23.3858, height: 33.1102 }
45
+ },
46
+ "a2" => {
47
+ "cm" => { width: 42, height: 59.4 },
48
+ "in" => { width: 16.5354, height: 23.3858 }
49
+ },
50
+ "a3" => {
51
+ "cm" => { width: 29.7, height: 42 },
52
+ "in" => { width: 11.6929, height: 16.5354 }
53
+ },
54
+ "a4" => {
55
+ "cm" => { width: 21, height: 29.7 },
56
+ "in" => { width: 8.2677, height: 11.6929 }
57
+ },
58
+ "a5" => {
59
+ "cm" => { width: 14.8, height: 21 },
60
+ "in" => { width: 5.8268, height: 8.2677 }
61
+ },
62
+ "a6" => {
63
+ "cm" => { width: 10.5, height: 14.8 },
64
+ "in" => { width: 4.1339, height: 5.8268 }
65
+ }
66
+ }.freeze
13
67
 
14
68
  attr_reader :browsing_context #: Core::BrowsingContext
15
69
  attr_reader :browser_context #: BrowserContext
@@ -23,6 +77,15 @@ module Puppeteer
23
77
  @browsing_context = browsing_context
24
78
  @timeout_settings = TimeoutSettings.new(DEFAULT_TIMEOUT)
25
79
  @emitter = Core::EventEmitter.new
80
+ @request_interception_semaphore = Async::Semaphore.new(1)
81
+ @request_handlers = begin
82
+ ObjectSpace::WeakMap.new
83
+ rescue NameError
84
+ {}
85
+ end
86
+ @request_interception = nil
87
+ @auth_interception = nil
88
+ @credentials = nil
26
89
  end
27
90
 
28
91
  # Event emitter delegation methods
@@ -33,7 +96,18 @@ module Puppeteer
33
96
  # @rbs &block: (untyped) -> void -- Event handler
34
97
  # @rbs return: void
35
98
  def on(event, &block)
36
- @emitter.on(event, &block)
99
+ return @emitter.on(event, &block) unless event.to_sym == :request
100
+
101
+ wrapper = @request_handlers[block]
102
+ unless wrapper
103
+ wrapper = lambda do |request|
104
+ request.enqueue_intercept_action do
105
+ block.call(request)
106
+ end
107
+ end
108
+ @request_handlers[block] = wrapper
109
+ end
110
+ @emitter.on(event, &wrapper)
37
111
  end
38
112
 
39
113
  # Register a one-time event listener
@@ -49,6 +123,16 @@ module Puppeteer
49
123
  # @rbs &block: (untyped) -> void -- Event handler to remove
50
124
  # @rbs return: void
51
125
  def off(event, &block)
126
+ if event.to_sym == :request && block
127
+ # WeakMap#delete was added in Ruby 3.3, so we need to handle older versions
128
+ wrapper = if @request_handlers.respond_to?(:delete)
129
+ @request_handlers.delete(block)
130
+ else
131
+ @request_handlers[block]
132
+ end
133
+ return @emitter.off(event, &(wrapper || block))
134
+ end
135
+
52
136
  @emitter.off(event, &block)
53
137
  end
54
138
 
@@ -60,6 +144,19 @@ module Puppeteer
60
144
  @emitter.emit(event, data)
61
145
  end
62
146
 
147
+ # @rbs return: bool -- Whether any network interception is enabled
148
+ def network_interception_enabled?
149
+ !@request_interception.nil? || !@auth_interception.nil?
150
+ end
151
+
152
+ # @rbs return: Hash[Symbol, String]?
153
+ def credentials
154
+ @credentials
155
+ end
156
+
157
+ # @rbs return: Async::Semaphore -- Serialize request interception handling
158
+ attr_reader :request_interception_semaphore
159
+
63
160
  # Navigate to a URL
64
161
  # @rbs url: String -- URL to navigate to
65
162
  # @rbs wait_until: String -- When to consider navigation complete ('load', 'domcontentloaded')
@@ -181,12 +278,18 @@ module Puppeteer
181
278
  }
182
279
  end
183
280
 
281
+ clip_x, clip_y, clip_width, clip_height = process_clip(
282
+ clip_x: box[:x],
283
+ clip_y: box[:y],
284
+ clip_width: box[:width],
285
+ clip_height: box[:height],
286
+ )
184
287
  options[:clip] = {
185
288
  type: 'box',
186
- x: box[:x],
187
- y: box[:y],
188
- width: box[:width],
189
- height: box[:height]
289
+ x: clip_x,
290
+ y: clip_y,
291
+ width: clip_width,
292
+ height: clip_height
190
293
  }
191
294
  end
192
295
 
@@ -206,6 +309,115 @@ module Puppeteer
206
309
  data
207
310
  end
208
311
 
312
+ # Generate a PDF of the page.
313
+ # @rbs path: String? -- File path to save PDF
314
+ # @rbs scale: Numeric? -- Scale of the webpage rendering
315
+ # @rbs display_header_footer: bool? -- Display header and footer
316
+ # @rbs header_template: String? -- HTML template for header
317
+ # @rbs footer_template: String? -- HTML template for footer
318
+ # @rbs print_background: bool? -- Print background graphics
319
+ # @rbs landscape: bool? -- Print in landscape orientation
320
+ # @rbs page_ranges: String? -- Paper ranges to print (e.g. "1-5, 8")
321
+ # @rbs format: String? -- Paper format (e.g. "A4")
322
+ # @rbs width: String | Numeric? -- Paper width
323
+ # @rbs height: String | Numeric? -- Paper height
324
+ # @rbs prefer_css_page_size: bool? -- Prefer CSS @page size
325
+ # @rbs margin: Hash[Symbol, String | Numeric]? -- Paper margins
326
+ # @rbs omit_background: bool? -- Omit background
327
+ # @rbs tagged: bool? -- Generate tagged PDF
328
+ # @rbs outline: bool? -- Generate document outline
329
+ # @rbs timeout: Numeric? -- Timeout in ms (0 disables)
330
+ # @rbs wait_for_fonts: bool? -- Wait for document fonts to load
331
+ # @rbs return: String -- PDF data as binary string
332
+ def pdf(
333
+ path: nil,
334
+ scale: nil,
335
+ display_header_footer: nil,
336
+ header_template: nil,
337
+ footer_template: nil,
338
+ print_background: nil,
339
+ landscape: nil,
340
+ page_ranges: nil,
341
+ format: nil,
342
+ width: nil,
343
+ height: nil,
344
+ prefer_css_page_size: nil,
345
+ margin: nil,
346
+ omit_background: nil,
347
+ tagged: nil,
348
+ outline: nil,
349
+ timeout: nil,
350
+ wait_for_fonts: nil
351
+ )
352
+ assert_not_closed
353
+
354
+ timeout_ms = timeout.nil? ? @timeout_settings.timeout : timeout
355
+
356
+ options = {
357
+ scale: scale,
358
+ display_header_footer: display_header_footer,
359
+ header_template: header_template,
360
+ footer_template: footer_template,
361
+ print_background: print_background,
362
+ landscape: landscape,
363
+ page_ranges: page_ranges,
364
+ format: format,
365
+ width: width,
366
+ height: height,
367
+ prefer_css_page_size: prefer_css_page_size,
368
+ margin: margin,
369
+ omit_background: omit_background,
370
+ tagged: tagged,
371
+ outline: outline,
372
+ wait_for_fonts: wait_for_fonts
373
+ }.compact
374
+
375
+ parsed = parse_pdf_options(options, "cm")
376
+ ranges = parsed[:page_ranges]
377
+ page_ranges = ranges && !ranges.empty? ? ranges.split(", ") : []
378
+
379
+ begin
380
+ if timeout_ms == 0
381
+ main_frame.isolated_realm.evaluate("() => document.fonts.ready")
382
+ else
383
+ AsyncUtils.async_timeout(timeout_ms, -> do
384
+ main_frame.isolated_realm.evaluate("() => document.fonts.ready")
385
+ end).wait
386
+ end
387
+ rescue Async::TimeoutError
388
+ raise TimeoutError, "Timed out after waiting #{timeout_ms}ms"
389
+ end
390
+
391
+ print_options = {
392
+ background: parsed[:print_background],
393
+ margin: parsed[:margin],
394
+ orientation: parsed[:landscape] ? "landscape" : "portrait",
395
+ page: {
396
+ width: parsed[:width],
397
+ height: parsed[:height]
398
+ },
399
+ pageRanges: page_ranges,
400
+ scale: parsed[:scale],
401
+ shrinkToFit: !parsed[:prefer_css_page_size]
402
+ }
403
+
404
+ begin
405
+ data = if timeout_ms == 0
406
+ @browsing_context.print(**print_options).wait
407
+ else
408
+ AsyncUtils.async_timeout(timeout_ms, -> do
409
+ @browsing_context.print(**print_options).wait
410
+ end).wait
411
+ end
412
+ rescue Async::TimeoutError
413
+ raise TimeoutError, "Timed out after waiting #{timeout_ms}ms"
414
+ end
415
+
416
+ pdf_data = Base64.decode64(data)
417
+ File.binwrite(path, pdf_data) if path
418
+ pdf_data
419
+ end
420
+
209
421
  # Evaluate JavaScript in the page context
210
422
  # @rbs script: String -- JavaScript code to evaluate
211
423
  # @rbs *args: untyped -- Arguments to pass to the script
@@ -236,6 +448,24 @@ module Puppeteer
236
448
  main_frame.query_selector_all(selector)
237
449
  end
238
450
 
451
+ # Create a locator for a selector or function.
452
+ # @rbs selector: String? -- Selector to locate
453
+ # @rbs function: String? -- JavaScript function for function locator
454
+ # @rbs return: Locator -- Locator instance
455
+ def locator(selector = nil, function: nil)
456
+ assert_not_closed
457
+
458
+ if function
459
+ raise ArgumentError, "selector and function cannot both be set" if selector
460
+
461
+ FunctionLocator.create(self, function)
462
+ elsif selector
463
+ NodeLocator.create(self, selector)
464
+ else
465
+ raise ArgumentError, "selector or function must be provided"
466
+ end
467
+ end
468
+
239
469
  # Evaluate a function on the first element matching the selector
240
470
  # @rbs selector: String -- Selector to query
241
471
  # @rbs page_function: String -- JavaScript function to evaluate
@@ -281,6 +511,15 @@ module Puppeteer
281
511
  main_frame.hover(selector)
282
512
  end
283
513
 
514
+ # Select options on a <select> element matching the selector
515
+ # Triggers 'change' and 'input' events once all options are selected.
516
+ # @rbs selector: String -- Selector for <select> element
517
+ # @rbs *values: String -- Option values to select
518
+ # @rbs return: Array[String] -- Actually selected option values
519
+ def select(selector, *values)
520
+ main_frame.select(selector, *values)
521
+ end
522
+
284
523
  # Focus an element matching the selector
285
524
  # @rbs selector: String -- Selector to focus
286
525
  # @rbs return: void
@@ -307,6 +546,31 @@ module Puppeteer
307
546
  @browsing_context.url
308
547
  end
309
548
 
549
+ # Get the full HTML contents of the page, including the DOCTYPE.
550
+ # @rbs return: String -- Full HTML contents
551
+ def content
552
+ assert_not_closed
553
+
554
+ # Port of Puppeteer's Frame.content().
555
+ # https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/api/Frame.ts
556
+ main_frame.evaluate(<<~JS)
557
+ () => {
558
+ let content = '';
559
+ for (const node of document.childNodes) {
560
+ switch (node) {
561
+ case document.documentElement:
562
+ content += document.documentElement.outerHTML;
563
+ break;
564
+ default:
565
+ content += new XMLSerializer().serializeToString(node);
566
+ break;
567
+ }
568
+ }
569
+ return content;
570
+ }
571
+ JS
572
+ end
573
+
310
574
  # Close the page
311
575
  # @rbs return: void
312
576
  def close
@@ -327,6 +591,72 @@ module Puppeteer
327
591
  @main_frame ||= Frame.from(self, @browsing_context)
328
592
  end
329
593
 
594
+ # Reloads the page.
595
+ # @rbs timeout: Numeric -- Navigation timeout in ms
596
+ # @rbs wait_until: String | Array[String] -- When to consider navigation complete
597
+ # @rbs ignore_cache: bool -- Whether to ignore the browser cache
598
+ # @rbs return: HTTPResponse? -- Response or nil
599
+ def reload(timeout: 30000, wait_until: 'load', ignore_cache: false)
600
+ assert_not_closed
601
+
602
+ reload_options = {}
603
+ reload_options[:ignoreCache] = true if ignore_cache
604
+
605
+ wait_for_navigation(timeout: timeout, wait_until: wait_until) do
606
+ @browsing_context.reload(**reload_options).wait
607
+ end
608
+ end
609
+
610
+ # Enable or disable request interception.
611
+ # @rbs enable: bool -- Whether to enable interception
612
+ # @rbs return: void
613
+ def set_request_interception(enable)
614
+ assert_not_closed
615
+
616
+ @request_interception = toggle_interception(
617
+ ["beforeRequestSent"],
618
+ @request_interception,
619
+ enable,
620
+ )
621
+ end
622
+
623
+ # Set extra HTTP headers for the page.
624
+ # @rbs headers: Hash[String, String] -- Extra headers
625
+ # @rbs return: void
626
+ def set_extra_http_headers(headers)
627
+ assert_not_closed
628
+
629
+ normalized = {}
630
+ headers.each do |key, value|
631
+ normalized[key.to_s] = value.to_s
632
+ end
633
+ @browsing_context.set_extra_http_headers(normalized).wait
634
+ end
635
+
636
+ # Authenticate to HTTP Basic auth challenges.
637
+ # @rbs credentials: Hash[Symbol, String]? -- Credentials (username/password) or nil to disable
638
+ # @rbs return: void
639
+ def authenticate(credentials)
640
+ assert_not_closed
641
+
642
+ @auth_interception = toggle_interception(
643
+ ["authRequired"],
644
+ @auth_interception,
645
+ !credentials.nil?,
646
+ )
647
+
648
+ @credentials = credentials
649
+ end
650
+
651
+ # Enable or disable cache.
652
+ # @rbs enabled: bool -- Whether to enable cache
653
+ # @rbs return: void
654
+ def set_cache_enabled(enabled)
655
+ assert_not_closed
656
+
657
+ @browsing_context.set_cache_behavior(enabled ? "default" : "bypass").wait
658
+ end
659
+
330
660
  # Get the focused frame
331
661
  # @rbs return: Frame -- Focused frame
332
662
  def focused_frame
@@ -430,6 +760,199 @@ module Puppeteer
430
760
  main_frame.wait_for_navigation(timeout: timeout, wait_until: wait_until, &block)
431
761
  end
432
762
 
763
+ # Wait for a request that matches a URL or predicate.
764
+ # @rbs url_or_predicate: String | ^(HTTPRequest) -> boolish -- URL or predicate
765
+ # @rbs timeout: Numeric? -- Timeout in ms (0 for infinite)
766
+ # @rbs &block: (-> void)? -- Optional block to trigger the request
767
+ # @rbs return: HTTPRequest
768
+ def wait_for_request(url_or_predicate, timeout: nil, &block)
769
+ assert_not_closed
770
+
771
+ timeout_ms = timeout.nil? ? @timeout_settings.timeout : timeout
772
+ predicate = if url_or_predicate.is_a?(Proc)
773
+ url_or_predicate
774
+ else
775
+ ->(request) { request.url == url_or_predicate }
776
+ end
777
+
778
+ promise = Async::Promise.new
779
+ listener = proc do |request|
780
+ next unless predicate.call(request)
781
+
782
+ promise.resolve(request) unless promise.resolved?
783
+ end
784
+
785
+ begin
786
+ on(:request, &listener)
787
+ Async(&block).wait if block
788
+
789
+ if timeout_ms == 0
790
+ promise.wait
791
+ else
792
+ AsyncUtils.async_timeout(timeout_ms, promise).wait
793
+ end
794
+ ensure
795
+ off(:request, &listener)
796
+ end
797
+ end
798
+
799
+ # Wait for a response that matches a URL or predicate.
800
+ # @rbs url_or_predicate: String | ^(HTTPResponse) -> boolish -- URL or predicate
801
+ # @rbs timeout: Numeric? -- Timeout in ms (0 for infinite)
802
+ # @rbs &block: (-> void)? -- Optional block to trigger the response
803
+ # @rbs return: HTTPResponse
804
+ def wait_for_response(url_or_predicate, timeout: nil, &block)
805
+ assert_not_closed
806
+
807
+ timeout_ms = timeout.nil? ? @timeout_settings.timeout : timeout
808
+ predicate = if url_or_predicate.is_a?(Proc)
809
+ url_or_predicate
810
+ else
811
+ ->(response) { response.url == url_or_predicate }
812
+ end
813
+
814
+ promise = Async::Promise.new
815
+ listener = proc do |response|
816
+ next unless predicate.call(response)
817
+
818
+ promise.resolve(response) unless promise.resolved?
819
+ end
820
+
821
+ begin
822
+ on(:response, &listener)
823
+ Async(&block).wait if block
824
+
825
+ if timeout_ms == 0
826
+ promise.wait
827
+ else
828
+ AsyncUtils.async_timeout(timeout_ms, promise).wait
829
+ end
830
+ ensure
831
+ off(:response, &listener)
832
+ end
833
+ end
834
+
835
+ # Retrieve cookies for the current page.
836
+ # @rbs *urls: Array[String] -- URLs to filter cookies by
837
+ # @rbs return: Array[Hash[String, untyped]]
838
+ def cookies(*urls)
839
+ assert_not_closed
840
+
841
+ normalized_urls = (urls.empty? ? [url] : urls).map do |cookie_url|
842
+ parse_cookie_url_strict(cookie_url)
843
+ end
844
+
845
+ @browsing_context.get_cookies.wait
846
+ .map { |cookie| CookieUtils.bidi_to_puppeteer_cookie(cookie) }
847
+ .select do |cookie|
848
+ normalized_urls.any? do |normalized_url|
849
+ CookieUtils.test_url_match_cookie(cookie, normalized_url)
850
+ end
851
+ end
852
+ end
853
+
854
+ # Set cookies for the current page.
855
+ # @rbs *cookies: Array[Hash[String, untyped]] -- Cookie data
856
+ # @rbs **cookie: untyped -- Single cookie via keyword arguments
857
+ # @rbs return: void
858
+ def set_cookie(*cookies, **cookie)
859
+ assert_not_closed
860
+
861
+ cookies = cookies.dup
862
+ cookies << cookie unless cookie.empty?
863
+
864
+ page_url = url
865
+ page_url_starts_with_http = page_url&.start_with?("http")
866
+
867
+ cookies.each do |raw_cookie|
868
+ normalized_cookie = CookieUtils.normalize_cookie_input(raw_cookie)
869
+ cookie_url = normalized_cookie["url"].to_s
870
+ if cookie_url.empty? && page_url_starts_with_http
871
+ cookie_url = page_url
872
+ end
873
+
874
+ if cookie_url == "about:blank"
875
+ raise ArgumentError, "Blank page can not have cookie \"#{normalized_cookie["name"]}\""
876
+ end
877
+ if cookie_url.start_with?("data:")
878
+ raise ArgumentError, "Data URL page can not have cookie \"#{normalized_cookie["name"]}\""
879
+ end
880
+
881
+ partition_key = normalized_cookie["partitionKey"]
882
+ if !partition_key.nil? && !partition_key.is_a?(String)
883
+ raise ArgumentError, "BiDi only allows domain partition keys"
884
+ end
885
+
886
+ normalized_url = parse_cookie_url(cookie_url)
887
+ domain = normalized_cookie["domain"] || normalized_url&.host
888
+ if domain.nil?
889
+ raise ArgumentError, "At least one of the url and domain needs to be specified"
890
+ end
891
+
892
+ bidi_cookie = {
893
+ "domain" => domain,
894
+ "name" => normalized_cookie["name"],
895
+ "value" => { "type" => "string", "value" => normalized_cookie["value"] },
896
+ }
897
+ bidi_cookie["path"] = normalized_cookie["path"] if normalized_cookie.key?("path")
898
+ bidi_cookie["httpOnly"] = normalized_cookie["httpOnly"] if normalized_cookie.key?("httpOnly")
899
+ bidi_cookie["secure"] = normalized_cookie["secure"] if normalized_cookie.key?("secure")
900
+ if normalized_cookie.key?("sameSite") && !normalized_cookie["sameSite"].nil?
901
+ bidi_cookie["sameSite"] = CookieUtils.convert_cookies_same_site_cdp_to_bidi(
902
+ normalized_cookie["sameSite"]
903
+ )
904
+ end
905
+ expiry = CookieUtils.convert_cookies_expiry_cdp_to_bidi(normalized_cookie["expires"])
906
+ bidi_cookie["expiry"] = expiry unless expiry.nil?
907
+ bidi_cookie.merge!(CookieUtils.cdp_specific_cookie_properties_from_puppeteer_to_bidi(
908
+ normalized_cookie,
909
+ "sameParty",
910
+ "sourceScheme",
911
+ "priority",
912
+ "url"
913
+ ))
914
+
915
+ if partition_key
916
+ @browser_context.user_context.set_cookie(bidi_cookie, source_origin: partition_key).wait
917
+ else
918
+ @browsing_context.set_cookie(bidi_cookie).wait
919
+ end
920
+ end
921
+ end
922
+
923
+ # Delete cookies from the current page.
924
+ # @rbs *cookies: Array[Hash[String, untyped]] -- Cookie filters
925
+ # @rbs **cookie: untyped -- Single cookie filter via keyword arguments
926
+ # @rbs return: void
927
+ def delete_cookie(*cookies, **cookie)
928
+ assert_not_closed
929
+
930
+ cookies = cookies.dup
931
+ cookies << cookie unless cookie.empty?
932
+
933
+ page_url = url
934
+
935
+ tasks = cookies.map do |raw_cookie|
936
+ normalized_cookie = CookieUtils.normalize_cookie_input(raw_cookie)
937
+ cookie_url = normalized_cookie["url"] || page_url
938
+ normalized_url = parse_cookie_url(cookie_url.to_s)
939
+ domain = normalized_cookie["domain"] || normalized_url&.host
940
+ if domain.nil?
941
+ raise ArgumentError, "At least one of the url and domain needs to be specified"
942
+ end
943
+
944
+ filter = {
945
+ "domain" => domain,
946
+ "name" => normalized_cookie["name"],
947
+ }
948
+ filter["path"] = normalized_cookie["path"] if normalized_cookie.key?("path")
949
+
950
+ -> { @browsing_context.delete_cookie(filter).wait }
951
+ end
952
+
953
+ AsyncUtils.await_promise_all(*tasks) unless tasks.empty?
954
+ end
955
+
433
956
  # Wait for a file chooser to be opened
434
957
  # @rbs timeout: Numeric? -- Wait timeout in ms
435
958
  # @rbs &block: (-> void)? -- Optional block to trigger file chooser
@@ -567,6 +1090,55 @@ module Puppeteer
567
1090
  ).wait
568
1091
  end
569
1092
 
1093
+ # Set geolocation override
1094
+ # @rbs longitude: Numeric -- Longitude between -180 and 180
1095
+ # @rbs latitude: Numeric -- Latitude between -90 and 90
1096
+ # @rbs accuracy: Numeric? -- Non-negative accuracy value
1097
+ # @rbs return: void
1098
+ def set_geolocation(longitude:, latitude:, accuracy: nil)
1099
+ assert_not_closed
1100
+
1101
+ if longitude < -180 || longitude > 180
1102
+ raise ArgumentError, "Invalid longitude \"#{longitude}\": precondition -180 <= LONGITUDE <= 180 failed."
1103
+ end
1104
+ if latitude < -90 || latitude > 90
1105
+ raise ArgumentError, "Invalid latitude \"#{latitude}\": precondition -90 <= LATITUDE <= 90 failed."
1106
+ end
1107
+ accuracy_value = accuracy.nil? ? 0 : accuracy
1108
+ if accuracy_value < 0
1109
+ raise ArgumentError, "Invalid accuracy \"#{accuracy_value}\": precondition 0 <= ACCURACY failed."
1110
+ end
1111
+
1112
+ coordinates = {
1113
+ latitude: latitude,
1114
+ longitude: longitude
1115
+ }
1116
+ coordinates[:accuracy] = accuracy unless accuracy.nil?
1117
+
1118
+ @browsing_context.set_geolocation_override(
1119
+ coordinates: coordinates
1120
+ ).wait
1121
+ end
1122
+
1123
+ # Set user agent
1124
+ # @rbs user_agent: String? -- User agent string or nil to restore original
1125
+ # @rbs user_agent_metadata: Hash[Symbol, untyped]? -- Not supported in BiDi-only mode
1126
+ # @rbs return: void
1127
+ def set_user_agent(user_agent, user_agent_metadata = nil)
1128
+ assert_not_closed
1129
+
1130
+ if user_agent.is_a?(Hash)
1131
+ raise UnsupportedOperationError, "options hash is not supported in BiDi-only mode"
1132
+ end
1133
+
1134
+ if user_agent_metadata
1135
+ raise UnsupportedOperationError, "userAgentMetadata is not supported in BiDi-only mode"
1136
+ end
1137
+
1138
+ user_agent = nil if user_agent == ""
1139
+ @browsing_context.set_user_agent(user_agent).wait
1140
+ end
1141
+
570
1142
  # Get current viewport size
571
1143
  # @rbs return: Hash[Symbol, Integer]? -- Viewport dimensions
572
1144
  def viewport
@@ -590,8 +1162,136 @@ module Puppeteer
590
1162
  @browsing_context.javascript_enabled?
591
1163
  end
592
1164
 
1165
+ # Navigate backward in history
1166
+ # @rbs wait_until: String -- When to consider navigation complete
1167
+ # @rbs timeout: Numeric -- Navigation timeout in ms
1168
+ # @rbs return: HTTPResponse? -- Response or nil
1169
+ def go_back(wait_until: 'load', timeout: 30000)
1170
+ go(-1, wait_until: wait_until, timeout: timeout)
1171
+ end
1172
+
1173
+ # Navigate forward in history
1174
+ # @rbs wait_until: String -- When to consider navigation complete
1175
+ # @rbs timeout: Numeric -- Navigation timeout in ms
1176
+ # @rbs return: HTTPResponse? -- Response or nil
1177
+ def go_forward(wait_until: 'load', timeout: 30000)
1178
+ go(1, wait_until: wait_until, timeout: timeout)
1179
+ end
1180
+
593
1181
  private
594
1182
 
1183
+ # Navigate history by delta.
1184
+ #
1185
+ # In Firefox, BFCache restores may emit `browsingContext.navigationStarted`
1186
+ # without firing `domContentLoaded` / `load`. We treat such navigations as
1187
+ # completed if we don't observe a navigation request shortly after start.
1188
+ # @rbs delta: Integer -- Steps to go back (negative) or forward (positive)
1189
+ # @rbs wait_until: String -- When to consider navigation complete
1190
+ # @rbs timeout: Numeric -- Navigation timeout in ms
1191
+ # @rbs return: HTTPResponse? -- Response or nil
1192
+ def go(delta, wait_until:, timeout:)
1193
+ assert_not_closed
1194
+
1195
+ load_event = wait_until == 'domcontentloaded' ? :dom_content_loaded : :load
1196
+
1197
+ started_promise = Async::Promise.new
1198
+ load_promise = Async::Promise.new
1199
+ navigation_request_promise = Async::Promise.new
1200
+
1201
+ navigation_id = nil
1202
+ navigation_url = nil
1203
+
1204
+ session = @browsing_context.user_context.browser.session
1205
+
1206
+ history_listener = proc do
1207
+ started_promise.resolve(:history_updated) unless started_promise.resolved?
1208
+ end
1209
+
1210
+ fragment_listener = proc do
1211
+ started_promise.resolve(:fragment_navigated) unless started_promise.resolved?
1212
+ end
1213
+
1214
+ nav_started_listener = proc do |info|
1215
+ next unless info['context'] == @browsing_context.id
1216
+
1217
+ navigation_id = info['navigation']
1218
+ navigation_url = info['url']
1219
+ started_promise.resolve(:navigation_started) unless started_promise.resolved?
1220
+ end
1221
+
1222
+ request_listener = proc do |data|
1223
+ request = data[:request]
1224
+ next unless navigation_id
1225
+ next unless request&.navigation == navigation_id
1226
+
1227
+ navigation_request_promise.resolve(nil) unless navigation_request_promise.resolved?
1228
+ end
1229
+
1230
+ load_listener = proc do
1231
+ load_promise.resolve(nil) unless load_promise.resolved?
1232
+ end
1233
+
1234
+ closed_listener = proc do
1235
+ started_promise.reject(PageClosedError.new) unless started_promise.resolved?
1236
+ end
1237
+
1238
+ begin
1239
+ session.on('browsingContext.navigationStarted', &nav_started_listener)
1240
+ @browsing_context.on(:history_updated, &history_listener)
1241
+ @browsing_context.on(:fragment_navigated, &fragment_listener)
1242
+ @browsing_context.on(:request, &request_listener)
1243
+ @browsing_context.once(load_event, &load_listener)
1244
+ @browsing_context.once(:closed, &closed_listener)
1245
+
1246
+ @browsing_context.traverse_history(delta).wait
1247
+ rescue Connection::ProtocolError => e
1248
+ # "History entry with delta X not found" - at history edge
1249
+ return nil if e.message.include?('not found')
1250
+ raise
1251
+ end
1252
+
1253
+ begin
1254
+ # If nothing starts soon, assume we're at the history edge.
1255
+ start_timeout_ms = [timeout.to_i, 500].min
1256
+ start_type = AsyncUtils.async_timeout(start_timeout_ms, started_promise).wait
1257
+ rescue Async::TimeoutError
1258
+ return nil
1259
+ end
1260
+
1261
+ case start_type
1262
+ when :history_updated, :fragment_navigated
1263
+ nil
1264
+ when :navigation_started
1265
+ # Determine BFCache restore (no navigation request) vs full navigation.
1266
+ begin
1267
+ AsyncUtils.async_timeout(200, navigation_request_promise).wait
1268
+
1269
+ begin
1270
+ AsyncUtils.async_timeout(timeout, load_promise).wait
1271
+ rescue Async::TimeoutError
1272
+ raise Puppeteer::Bidi::TimeoutError, "Navigation timeout of #{timeout}ms exceeded"
1273
+ end
1274
+
1275
+ HTTPResponse.for_bfcache(url: @browsing_context.url)
1276
+ rescue Async::TimeoutError
1277
+ # No navigation request observed: treat as BFCache restore.
1278
+ @browsing_context.instance_variable_set(:@url, navigation_url) if navigation_url
1279
+ @browsing_context.navigation&.dispose unless @browsing_context.navigation&.disposed?
1280
+
1281
+ HTTPResponse.for_bfcache(url: navigation_url || @browsing_context.url)
1282
+ end
1283
+ else
1284
+ nil
1285
+ end
1286
+ ensure
1287
+ session.off('browsingContext.navigationStarted', &nav_started_listener)
1288
+ @browsing_context.off(:history_updated, &history_listener)
1289
+ @browsing_context.off(:fragment_navigated, &fragment_listener)
1290
+ @browsing_context.off(:request, &request_listener)
1291
+ @browsing_context.off(load_event, &load_listener)
1292
+ @browsing_context.off(:closed, &closed_listener)
1293
+ end
1294
+
595
1295
  # Recursively collect all frames starting from the given frame
596
1296
  # @rbs frame: Frame -- Starting frame
597
1297
  # @rbs return: Array[Frame] -- All frames in subtree
@@ -603,11 +1303,163 @@ module Puppeteer
603
1303
  result
604
1304
  end
605
1305
 
1306
+ def parse_pdf_options(options, length_unit)
1307
+ defaults = {
1308
+ scale: 1,
1309
+ display_header_footer: false,
1310
+ header_template: "",
1311
+ footer_template: "",
1312
+ print_background: false,
1313
+ landscape: false,
1314
+ page_ranges: "",
1315
+ prefer_css_page_size: false,
1316
+ omit_background: false,
1317
+ outline: false,
1318
+ tagged: true,
1319
+ wait_for_fonts: true
1320
+ }
1321
+
1322
+ width = 8.5
1323
+ height = 11
1324
+ format = options[:format]
1325
+ if format
1326
+ format_key = format.to_s.downcase
1327
+ format_dimensions = PAPER_FORMATS[format_key]
1328
+ raise "Unknown paper format: #{format}" unless format_dimensions
1329
+
1330
+ dimensions = format_dimensions[length_unit]
1331
+ width = dimensions[:width]
1332
+ height = dimensions[:height]
1333
+ else
1334
+ width = convert_print_parameter_to_length_unit(options[:width], length_unit) || width
1335
+ height = convert_print_parameter_to_length_unit(options[:height], length_unit) || height
1336
+ end
1337
+
1338
+ margin_options = options[:margin]
1339
+ if margin_options.is_a?(Hash)
1340
+ margin_options = margin_options.transform_keys(&:to_sym)
1341
+ else
1342
+ margin_options = {}
1343
+ end
1344
+ margin = {
1345
+ top: convert_print_parameter_to_length_unit(margin_options[:top], length_unit) || 0,
1346
+ left: convert_print_parameter_to_length_unit(margin_options[:left], length_unit) || 0,
1347
+ bottom: convert_print_parameter_to_length_unit(margin_options[:bottom], length_unit) || 0,
1348
+ right: convert_print_parameter_to_length_unit(margin_options[:right], length_unit) || 0
1349
+ }
1350
+
1351
+ options[:tagged] = true if options[:outline]
1352
+
1353
+ defaults.merge(options).merge(
1354
+ width: width,
1355
+ height: height,
1356
+ margin: margin
1357
+ )
1358
+ end
1359
+
1360
+ def convert_print_parameter_to_length_unit(parameter, length_unit)
1361
+ return nil if parameter.nil?
1362
+
1363
+ pixels = nil
1364
+
1365
+ if parameter.is_a?(Numeric)
1366
+ pixels = parameter
1367
+ elsif parameter.is_a?(String)
1368
+ text = parameter
1369
+ unit = text[-2, 2].to_s.downcase
1370
+ if UNIT_TO_PIXELS.key?(unit)
1371
+ value_text = text[0...-2]
1372
+ else
1373
+ unit = "px"
1374
+ value_text = text
1375
+ end
1376
+ value = parse_print_parameter_value(text, value_text)
1377
+ pixels = value * UNIT_TO_PIXELS[unit]
1378
+ else
1379
+ raise "page.pdf() Cannot handle parameter type: #{js_typeof(parameter)}"
1380
+ end
1381
+
1382
+ pixels / UNIT_TO_PIXELS.fetch(length_unit)
1383
+ end
1384
+
1385
+ def parse_print_parameter_value(text, value_text)
1386
+ value = Float(value_text)
1387
+ raise "Failed to parse parameter value: #{text}" if value.nan?
1388
+
1389
+ value
1390
+ rescue ArgumentError
1391
+ raise "Failed to parse parameter value: #{text}"
1392
+ end
1393
+
1394
+ def js_typeof(value)
1395
+ case value
1396
+ when String
1397
+ "string"
1398
+ when Numeric
1399
+ "number"
1400
+ when TrueClass, FalseClass
1401
+ "boolean"
1402
+ when Proc, Method
1403
+ "function"
1404
+ else
1405
+ "object"
1406
+ end
1407
+ end
1408
+
606
1409
  # Check if this page is closed and raise error if so
607
1410
  # @rbs return: void
608
1411
  def assert_not_closed
609
1412
  raise PageClosedError if closed?
610
1413
  end
1414
+
1415
+ # @rbs cookie_url: String -- Cookie URL
1416
+ # @rbs return: URI::Generic? -- Parsed URL or nil
1417
+ def parse_cookie_url(cookie_url)
1418
+ return nil if cookie_url.nil? || cookie_url.empty?
1419
+
1420
+ URI.parse(cookie_url)
1421
+ rescue URI::InvalidURIError
1422
+ nil
1423
+ end
1424
+
1425
+ def parse_cookie_url_strict(cookie_url)
1426
+ normalized_url = URI.parse(cookie_url.to_s)
1427
+ if normalized_url.scheme.nil? ||
1428
+ (normalized_url.scheme.match?(/\Ahttps?\z/i) && normalized_url.host.to_s.empty?)
1429
+ raise ArgumentError, "Invalid URL"
1430
+ end
1431
+ normalized_url
1432
+ rescue URI::InvalidURIError
1433
+ raise ArgumentError, "Invalid URL"
1434
+ end
1435
+
1436
+ def toggle_interception(phases, interception, expected)
1437
+ if expected && interception.nil?
1438
+ return @browsing_context.add_intercept(phases: phases).wait
1439
+ end
1440
+ if !expected && interception
1441
+ @browsing_context.user_context.browser.remove_intercept(interception).wait
1442
+ return nil
1443
+ end
1444
+ interception
1445
+ end
1446
+
1447
+ # @rbs clip_x: Numeric -- Clip x coordinate
1448
+ # @rbs clip_y: Numeric -- Clip y coordinate
1449
+ # @rbs clip_width: Numeric -- Clip width
1450
+ # @rbs clip_height: Numeric -- Clip height
1451
+ # @rbs return: Array[Integer] -- Processed clip [x, y, width, height]
1452
+ # ref: https://github.com/puppeteer/puppeteer/commit/2275c3c0c801d42514d6de127b9b1537d20356a9
1453
+ def process_clip(clip_x:, clip_y:, clip_width:, clip_height:)
1454
+ rounded_x = clip_x.round
1455
+ rounded_y = clip_y.round
1456
+ [
1457
+ rounded_x,
1458
+ rounded_y,
1459
+ (clip_width + (clip_x - rounded_x)).round,
1460
+ (clip_height + (clip_y - rounded_y)).round,
1461
+ ]
1462
+ end
611
1463
  end
612
1464
  end
613
1465
  end