bidi2pdf 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. checksums.yaml +4 -4
  2. data/.devcontainer/Dockerfile +53 -0
  3. data/.devcontainer/devcontainer.json +51 -0
  4. data/.devcontainer/docker-compose.yml +33 -0
  5. data/.ruby-gemset +1 -1
  6. data/.ruby-version +1 -1
  7. data/CHANGELOG.md +93 -6
  8. data/README.md +80 -5
  9. data/docker/Dockerfile +1 -1
  10. data/docker/Dockerfile.slim +2 -2
  11. data/docker/chrome-driver/chrome.json +874 -0
  12. data/lib/bidi2pdf/bidi/browser_tab.rb +97 -4
  13. data/lib/bidi2pdf/bidi/buffered_web_socket_client.rb +178 -0
  14. data/lib/bidi2pdf/bidi/client.rb +12 -5
  15. data/lib/bidi2pdf/bidi/commands/base.rb +2 -0
  16. data/lib/bidi2pdf/bidi/commands/capture_screenshot.rb +33 -0
  17. data/lib/bidi2pdf/bidi/commands/set_viewport.rb +32 -0
  18. data/lib/bidi2pdf/bidi/commands.rb +2 -0
  19. data/lib/bidi2pdf/bidi/network_event.rb +12 -3
  20. data/lib/bidi2pdf/bidi/network_events.rb +9 -1
  21. data/lib/bidi2pdf/bidi/session.rb +1 -1
  22. data/lib/bidi2pdf/chromedriver_manager.rb +10 -6
  23. data/lib/bidi2pdf/cli.rb +11 -1
  24. data/lib/bidi2pdf/launcher.rb +5 -3
  25. data/lib/bidi2pdf/notifications/logging_subscriber.rb +2 -0
  26. data/lib/bidi2pdf/session_warmer.rb +377 -0
  27. data/lib/bidi2pdf/test_helpers/images.rb +8 -0
  28. data/lib/bidi2pdf/test_helpers/pdf_reader_utils.rb +1 -2
  29. data/lib/bidi2pdf/test_helpers/pdf_text_sanitizer.rb +1 -2
  30. data/lib/bidi2pdf/test_helpers/testcontainers/chromedriver_container.rb +1 -1
  31. data/lib/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rb +22 -2
  32. data/lib/bidi2pdf/test_helpers/testcontainers/container_endpoint.rb +30 -0
  33. data/lib/bidi2pdf/test_helpers/testcontainers/testcontainers_refinement.rb +33 -0
  34. data/lib/bidi2pdf/test_helpers/testcontainers.rb +1 -0
  35. data/lib/bidi2pdf/version.rb +1 -1
  36. data/lib/bidi2pdf.rb +26 -1
  37. data/sig/bidi2pdf/bidi/add_headers_interceptor.rbs +16 -9
  38. data/sig/bidi2pdf/bidi/auth_interceptor.rbs +32 -7
  39. data/sig/bidi2pdf/bidi/browser.rbs +4 -31
  40. data/sig/bidi2pdf/bidi/browser_console_logger.rbs +59 -0
  41. data/sig/bidi2pdf/bidi/browser_tab.rbs +269 -25
  42. data/sig/bidi2pdf/bidi/buffered_web_socket_client.rbs +81 -0
  43. data/sig/bidi2pdf/bidi/client.rbs +103 -43
  44. data/sig/bidi2pdf/bidi/command_manager.rbs +5 -17
  45. data/sig/bidi2pdf/bidi/commands/add_intercept.rbs +16 -8
  46. data/sig/bidi2pdf/bidi/commands/base.rbs +49 -9
  47. data/sig/bidi2pdf/bidi/commands/browser_close.rbs +3 -1
  48. data/sig/bidi2pdf/bidi/commands/browser_create_user_context.rbs +2 -0
  49. data/sig/bidi2pdf/bidi/commands/browser_remove_user_context.rbs +19 -0
  50. data/sig/bidi2pdf/bidi/commands/browsing_context_close.rbs +8 -2
  51. data/sig/bidi2pdf/bidi/commands/browsing_context_navigate.rbs +12 -6
  52. data/sig/bidi2pdf/bidi/commands/browsing_context_print.rbs +10 -5
  53. data/sig/bidi2pdf/bidi/commands/cancel_auth.rbs +8 -2
  54. data/sig/bidi2pdf/bidi/commands/capture_screenshot.rbs +31 -0
  55. data/sig/bidi2pdf/bidi/commands/cdp_get_session.rbs +17 -0
  56. data/sig/bidi2pdf/bidi/commands/create_window.rbs +4 -0
  57. data/sig/bidi2pdf/bidi/commands/get_user_contexts.rbs +2 -0
  58. data/sig/bidi2pdf/bidi/commands/network_continue.rbs +4 -0
  59. data/sig/bidi2pdf/bidi/commands/page_print.rbs +28 -0
  60. data/sig/bidi2pdf/bidi/commands/print_parameters_validator.rbs +3 -0
  61. data/sig/bidi2pdf/bidi/commands/provide_credentials.rbs +12 -6
  62. data/sig/bidi2pdf/bidi/commands/script_evaluate.rbs +4 -0
  63. data/sig/bidi2pdf/bidi/commands/session_end.rbs +2 -0
  64. data/sig/bidi2pdf/bidi/commands/session_status.rbs +3 -1
  65. data/sig/bidi2pdf/bidi/commands/session_subscribe.rbs +4 -0
  66. data/sig/bidi2pdf/bidi/commands/set_tab_cookie.rbs +51 -23
  67. data/sig/bidi2pdf/bidi/commands/set_usercontext_cookie.rbs +17 -19
  68. data/sig/bidi2pdf/bidi/commands/set_viewport.rbs +31 -0
  69. data/sig/bidi2pdf/bidi/connection_manager.rbs +2 -1
  70. data/sig/bidi2pdf/bidi/event_manager.rbs +14 -19
  71. data/sig/bidi2pdf/bidi/interceptor.rbs +5 -0
  72. data/sig/bidi2pdf/bidi/js_logger_helper.rbs +9 -0
  73. data/sig/bidi2pdf/bidi/logger_events.rbs +21 -0
  74. data/sig/bidi2pdf/bidi/navigation_failed_events.rbs +17 -0
  75. data/sig/bidi2pdf/bidi/network_event.rbs +64 -46
  76. data/sig/bidi2pdf/bidi/network_event_formatters/network_event_console_formatter.rbs +63 -0
  77. data/sig/bidi2pdf/bidi/network_event_formatters/network_event_formatter_utils.rbs +15 -0
  78. data/sig/bidi2pdf/bidi/network_event_formatters/network_event_html_formatter.rbs +18 -0
  79. data/sig/bidi2pdf/bidi/network_event_formatters.rbs +6 -0
  80. data/sig/bidi2pdf/bidi/network_events.rbs +14 -39
  81. data/sig/bidi2pdf/bidi/session.rbs +144 -31
  82. data/sig/bidi2pdf/bidi/user_context.rbs +64 -45
  83. data/sig/bidi2pdf/bidi/web_socket_dispatcher.rbs +22 -30
  84. data/sig/bidi2pdf/chromedriver_manager.rbs +61 -22
  85. data/sig/bidi2pdf/cli.rbs +22 -2
  86. data/sig/bidi2pdf/dsl.rbs +34 -0
  87. data/sig/bidi2pdf/launcher.rbs +39 -1
  88. data/sig/bidi2pdf/notifications/event.rbs +40 -0
  89. data/sig/bidi2pdf/notifications/instrumenter.rbs +21 -0
  90. data/sig/bidi2pdf/notifications/logging_subscriber.rbs +41 -0
  91. data/sig/bidi2pdf/notifications.rbs +24 -0
  92. data/sig/bidi2pdf/process_tree.rbs +2 -2
  93. data/sig/bidi2pdf/session_runner.rbs +41 -1
  94. data/sig/bidi2pdf/session_warmer.rbs +221 -0
  95. data/sig/bidi2pdf/test_helpers/configuration.rbs +51 -0
  96. data/sig/bidi2pdf/test_helpers/images/extractor.rbs +43 -0
  97. data/sig/bidi2pdf/test_helpers/images/image_similarity_checker.rbs +35 -0
  98. data/sig/bidi2pdf/test_helpers/images/tiff_helper.rbs +70 -0
  99. data/sig/bidi2pdf/test_helpers/images.rbs +0 -0
  100. data/sig/bidi2pdf/test_helpers/matchers/contains_pdf_image.rbs +0 -0
  101. data/sig/bidi2pdf/test_helpers/matchers/contains_pdf_text.rbs +0 -0
  102. data/sig/bidi2pdf/test_helpers/matchers/have_pdf_page_count.rbs +0 -0
  103. data/sig/bidi2pdf/test_helpers/matchers/match_pdf_text.rbs +0 -0
  104. data/sig/bidi2pdf/test_helpers/pdf_file_helper.rbs +23 -0
  105. data/sig/bidi2pdf/test_helpers/pdf_reader_utils.rbs +49 -0
  106. data/sig/bidi2pdf/test_helpers/pdf_text_sanitizer.rbs +38 -0
  107. data/sig/bidi2pdf/test_helpers/spec_paths_helper.rbs +35 -0
  108. data/sig/bidi2pdf/test_helpers/testcontainers/chromedriver_container.rbs +43 -0
  109. data/sig/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rbs +45 -0
  110. data/sig/bidi2pdf/test_helpers/testcontainers/container_endpoint.rbs +25 -0
  111. data/sig/bidi2pdf/test_helpers/testcontainers/shared_docker_network.rbs +3 -0
  112. data/sig/bidi2pdf/test_helpers/testcontainers/testcontainers_refinement.rbs +47 -0
  113. data/sig/bidi2pdf/test_helpers/testcontainers.rbs +6 -0
  114. data/sig/bidi2pdf/test_helpers.rbs +0 -0
  115. data/sig/bidi2pdf/verbose_logger.rbs +37 -0
  116. data/sig/bidi2pdf/version.rbs +3 -0
  117. data/sig/bidi2pdf.rbs +130 -2
  118. data/tasks/generate_rbs.rake +11 -11
  119. data/tasks/release_credentials_check.rake +97 -0
  120. metadata +67 -10
  121. data/sig/bidi2pdf/utils.rbs +0 -5
@@ -444,6 +444,58 @@ module Bidi2pdf
444
444
 
445
445
  # rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity
446
446
 
447
+ # Captures a screenshot of the browser tab, mirroring #print's own
448
+ # file/block/base64-return trichotomy.
449
+ #
450
+ # @param [String, nil] outputfile The output file for the PNG. Defaults to nil.
451
+ # @param [String] origin "document" (default) or "viewport" - see browsingContext.captureScreenshot.
452
+ # @param [String, nil] format Image format override, e.g. "image/png". Defaults to the browser's own default.
453
+ # @param [Hash, nil] clip A clip rectangle/element, passed through verbatim. Defaults to nil (full capture).
454
+ # @yield [png_base64] A block to handle the PNG content.
455
+ # @return [String, nil] The base64-encoded PNG content, or nil if outputfile or block is provided.
456
+ # rubocop:disable Metrics/AbcSize
457
+ def screenshot(outputfile = nil, origin: "document", format: nil, clip: nil, &block)
458
+ Bidi2pdf.notification_service.instrument("screenshot.bidi2pdf") do |instrumentation_payload|
459
+ cmd = Commands::CaptureScreenshot.new(context: browsing_context_id, origin: origin, format: format, clip: clip)
460
+
461
+ instrumentation_payload[:cmd] = cmd
462
+
463
+ client.send_cmd_and_wait(cmd) do |response|
464
+ if response["result"]
465
+ png_base64 = response.dig("result", "data")
466
+
467
+ instrumentation_payload[:png_base64] = png_base64
468
+
469
+ if outputfile
470
+ raise ScreenshotError, "Folder does not exist: #{File.dirname(outputfile)}" unless File.directory?(File.dirname(outputfile))
471
+
472
+ File.binwrite(outputfile, Base64.decode64(png_base64))
473
+ Bidi2pdf.logger.info "Screenshot saved as '#{outputfile}'."
474
+ end
475
+
476
+ block.call(png_base64) if block_given?
477
+
478
+ return png_base64 unless outputfile || block_given?
479
+ else
480
+ Bidi2pdf.logger.error "Error capturing screenshot: #{response}"
481
+ end
482
+ end
483
+ end
484
+ end
485
+
486
+ # rubocop:enable Metrics/AbcSize
487
+
488
+ # Sets the browsing context's viewport - makes a headless render deterministic across
489
+ # machines, unlike leaving it at the browser's own default.
490
+ #
491
+ # @param [Integer] width
492
+ # @param [Integer] height
493
+ # @param [Float, nil] device_pixel_ratio Defaults to nil (browser default).
494
+ def set_viewport(width:, height:, device_pixel_ratio: nil)
495
+ cmd = Commands::SetViewport.new(context: browsing_context_id, width: width, height: height, device_pixel_ratio: device_pixel_ratio)
496
+ client.send_cmd_and_wait(cmd) { |response| response }
497
+ end
498
+
447
499
  private
448
500
 
449
501
  def build_command_and_extractor(print_options)
@@ -481,11 +533,21 @@ module Bidi2pdf
481
533
 
482
534
  cmd = Bidi2pdf::Bidi::Commands::BrowsingContextNavigate.new url: url, context: browsing_context_id, wait: wait
483
535
 
484
- client.send_cmd_and_wait(cmd) do |response|
485
- Bidi2pdf.logger.debug "Navigated to page url: #{url} response: #{response}"
536
+ navigation_id = client.send_cmd_and_wait(cmd) do |response|
537
+ logged_url = url.start_with?("data:") ? "data:[#{url.bytesize} bytes]" : url
538
+ response_navigation_id = response.dig("result", "navigation")
539
+ Bidi2pdf.logger.debug "Navigated to page url: #{logged_url} navigation: #{response_navigation_id}"
540
+
541
+ response_navigation_id
486
542
  end
543
+
544
+ check_navigation_http_status(url, navigation_id)
487
545
  rescue Bidi2pdf::CmdError => e
488
- msg = e.response["message"]
546
+ raise_navigation_error_for(url, e)
547
+ end
548
+
549
+ def raise_navigation_error_for(url, error)
550
+ msg = error.response["message"]
489
551
  case msg
490
552
  when /^net::ERR_INVALID_AUTH_CREDENTIALS/
491
553
  raise NavigationAuthError.new(url, msg)
@@ -494,10 +556,41 @@ module Bidi2pdf
494
556
  when /^net::/
495
557
  raise NavigationError, "Connection error: #{url} #{msg}"
496
558
  else
497
- raise e
559
+ raise error
498
560
  end
499
561
  end
500
562
 
563
+ # browsingContext.navigate's own response never carries an HTTP status - only a
564
+ # network.responseCompleted event does, correlated back to this specific navigation via its
565
+ # "navigation" field (a Chrome/redirect-chain-wide ID, not the network request's own id).
566
+ # register_event_listeners already ran above, so network_events has been tracking since
567
+ # before the navigate command was even sent. Deliberately conservative: with no navigation
568
+ # ID, or no correlated *completed* request found, this stays silent rather than guessing -
569
+ # only a positively confirmed >= 400 status raises. max_by(&:start_timestamp) picks the last
570
+ # hop of a redirect chain (every hop shares the same navigation ID), matching the page the
571
+ # browser actually ended up on.
572
+ def check_navigation_http_status(url, navigation_id)
573
+ return unless navigation_id
574
+
575
+ final_response = correlated_navigation_response(navigation_id)
576
+ return unless final_response
577
+
578
+ status = final_response.http_status_code
579
+ return if status < 400
580
+
581
+ raise NavigationNotFoundError, "Navigation to #{url} failed: HTTP 404 Not Found" if status == 404
582
+
583
+ raise NavigationError, "Navigation to #{url} failed: HTTP #{status}"
584
+ end
585
+
586
+ # The last hop of a redirect chain (every hop shares the same navigation ID) - the page the
587
+ # browser actually ended up on, not wherever the chain started.
588
+ def correlated_navigation_response(navigation_id)
589
+ network_events.all_events
590
+ .select { |event| event.navigation == navigation_id && event.http_status_code }
591
+ .max_by(&:start_timestamp)
592
+ end
593
+
501
594
  def register_event_listeners
502
595
  return if @event_handlers_registered
503
596
 
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require "openssl"
5
+ require "socket"
6
+ require "uri"
7
+ require "websocket"
8
+
9
+ module Bidi2pdf
10
+ module Bidi
11
+ # A threaded WebSocket client on the `websocket` gem's framing - the same shape Selenium's Ruby
12
+ # BiDi client uses. It replaces websocket-client-simple, whose reader pulled one byte at a time
13
+ # (`getc`, then a frame-parse attempt per byte): a printed PDF comes back as one base64 message
14
+ # and every network event for a `data:` navigation echoes the whole URL, so that loop ran
15
+ # millions of times per render - ~730 ms to decode a 700 KB frame, against ~0.5 ms in 16 KB
16
+ # chunks.
17
+ #
18
+ # Emits :open, :message (a WebSocket frame, payload in #data), :error and :close.
19
+ class BufferedWebSocketClient
20
+ READ_CHUNK_BYTES = 16_384
21
+
22
+ attr_reader :url
23
+
24
+ def self.connect(url, options = {})
25
+ client = new
26
+ yield client if block_given?
27
+ client.connect url, options
28
+ client
29
+ end
30
+
31
+ def initialize
32
+ @listeners = Hash.new { |hash, event| hash[event] = [] }
33
+ @listeners_mutex = Mutex.new
34
+ @write_mutex = Mutex.new
35
+ # Re-entrant: a failed write inside #close closes again.
36
+ @close_monitor = Monitor.new
37
+ @handshaked = false
38
+ @closed = false
39
+ end
40
+
41
+ def on(event, &listener)
42
+ @listeners_mutex.synchronize { @listeners[event] << listener }
43
+ listener
44
+ end
45
+
46
+ def connect(url, options = {})
47
+ return if @socket
48
+
49
+ @url = url
50
+ @socket = open_socket(URI.parse(url), options)
51
+ ::WebSocket.should_raise = true
52
+ @handshake = ::WebSocket::Handshake::Client.new url: url, headers: options[:headers]
53
+
54
+ socket = @socket # #close clears the ivar; the reader keeps its own reference
55
+ @thread = Thread.new { read_loop(socket, ::WebSocket::Frame::Incoming::Client.new) }
56
+ write @handshake.to_s
57
+ end
58
+
59
+ # Commands are sent from whichever thread issues them. On a plain TCP socket one IO#write is
60
+ # already atomic, but OpenSSL::SSL::SSLSocket#write is not, so writes share a lock for wss://.
61
+ def send(data, type: :text)
62
+ return unless open?
63
+
64
+ write ::WebSocket::Frame::Outgoing::Client.new(data: data, type: type, version: @handshake.version).to_s
65
+ rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => e
66
+ close e
67
+ end
68
+
69
+ # Safe from any thread, including the reader's own: the reader sees the peer hang up and
70
+ # closes from inside its loop, so it must neither race a caller closing at the same moment
71
+ # nor be killed before :close has been emitted.
72
+ def close(error = nil)
73
+ @close_monitor.synchronize do
74
+ return if @closed
75
+
76
+ say_goodbye unless error
77
+ @closed = true
78
+ @socket&.close
79
+ @socket = nil
80
+ emit :close, error
81
+ @thread.kill if @thread && @thread != Thread.current
82
+ end
83
+ end
84
+
85
+ def open? = @handshaked && !@closed
86
+
87
+ def closed? = @closed
88
+
89
+ private
90
+
91
+ def emit(event, *)
92
+ @listeners_mutex.synchronize { @listeners[event].dup }.each { |listener| listener.call(*) }
93
+ end
94
+
95
+ def write(bytes)
96
+ @write_mutex.synchronize { @socket&.write bytes }
97
+ end
98
+
99
+ def say_goodbye
100
+ write ::WebSocket::Frame::Outgoing::Client.new(data: nil, type: :close, version: @handshake.version).to_s if @handshaked
101
+ rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
102
+ nil # the peer is already gone; there is nobody left to say goodbye to
103
+ end
104
+
105
+ def open_socket(uri, options)
106
+ tcp_socket = TCPSocket.new(uri.host, uri.port || (uri.scheme == "wss" ? 443 : 80))
107
+ return tcp_socket unless %w[https wss].include?(uri.scheme)
108
+
109
+ ::OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context(options)).tap do |ssl_socket|
110
+ ssl_socket.sync_close = true
111
+ ssl_socket.hostname = uri.host
112
+ ssl_socket.connect
113
+ end
114
+ end
115
+
116
+ def ssl_context(options)
117
+ ::OpenSSL::SSL::SSLContext.new.tap do |context|
118
+ context.ssl_version = options[:ssl_version] if options[:ssl_version]
119
+ context.verify_mode = options[:verify_mode] if options[:verify_mode]
120
+ context.cert_store = (options[:cert_store] || ::OpenSSL::X509::Store.new).tap(&:set_default_paths)
121
+ end
122
+ end
123
+
124
+ def read_loop(socket, frame)
125
+ until @closed
126
+ begin
127
+ consume(socket.readpartial(READ_CHUNK_BYTES), frame)
128
+ # Same terminal-error list as #send/#say_goodbye - Errno::ECONNRESET is already a
129
+ # SystemCallError, so listing it separately was both redundant and, worse, incomplete: any
130
+ # *other* SystemCallError or OpenSSL::SSL::SSLError fell through to the generic rescue
131
+ # below, which only emits :error and loops back into readpartial - on a permanently broken
132
+ # socket (not just ECONNRESET) that re-raises the same error every iteration forever
133
+ # instead of ever closing.
134
+ rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => e # EOFError is an IOError
135
+ close e
136
+ rescue StandardError => e
137
+ emit :error, e
138
+ end
139
+ end
140
+ end
141
+
142
+ def consume(chunk, frame)
143
+ if @handshaked
144
+ frame << chunk
145
+ else
146
+ @handshake << chunk
147
+ return unless @handshake.finished?
148
+
149
+ @handshaked = true
150
+ emit :open
151
+ # Anything the server sent straight after its handshake response is already in this chunk.
152
+ frame << @handshake.leftovers unless @handshake.leftovers.to_s.empty?
153
+ end
154
+
155
+ while (message = frame.next)
156
+ dispatch_frame(message)
157
+ end
158
+ end
159
+
160
+ # WebSocket control frames (ping/pong/close) must never reach a JSON-parsing consumer -
161
+ # WebSocketDispatcher tries to parse every :message payload as JSON, so a raw close frame
162
+ # would raise JSON::ParserError, and an unanswered ping can make chromedriver or an
163
+ # intermediate proxy tear down an otherwise healthy connection on its own keepalive timeout.
164
+ def dispatch_frame(message)
165
+ case message.type
166
+ when :ping
167
+ send(message.data, type: :pong)
168
+ when :pong
169
+ nil
170
+ when :close
171
+ close
172
+ else
173
+ emit :message, message
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "websocket-client-simple"
5
-
4
+ require_relative "buffered_web_socket_client"
6
5
  require_relative "web_socket_dispatcher"
7
6
  require_relative "command_manager"
8
7
  require_relative "connection_manager"
@@ -44,11 +43,11 @@ module Bidi2pdf
44
43
 
45
44
  # Starts the WebSocket client and establishes a connection.
46
45
  #
47
- # @return [WebSocket::Client::Simple] The WebSocket connection object.
46
+ # @return [BufferedWebSocketClient] The WebSocket connection object.
48
47
  def start
49
48
  return @socket if started?
50
49
 
51
- WebSocket::Client::Simple.connect(ws_url) do |socket|
50
+ BufferedWebSocketClient.connect(ws_url) do |socket|
52
51
  @socket = socket
53
52
  @command_manager = CommandManager.new(@socket)
54
53
 
@@ -62,11 +61,19 @@ module Bidi2pdf
62
61
  @socket
63
62
  end
64
63
 
65
- # Checks if the WebSocket client has started.
64
+ # Checks if the WebSocket client has started. Unlike #open?, this never flips back to false on
65
+ # its own if the connection dies externally - it only reflects whether #start has run.
66
66
  #
67
67
  # @return [Boolean] True if the client has started, false otherwise.
68
68
  def started? = @started
69
69
 
70
+ # Checks if the underlying WebSocket connection is actually open right now - false before
71
+ # #start, after #close, or if the reader thread noticed the connection died on its own (a
72
+ # real liveness signal, not just an internal "did we ever start" flag).
73
+ #
74
+ # @return [Boolean] True if the underlying socket reports itself open.
75
+ def open? = !!@socket&.open?
76
+
70
77
  # Waits until the WebSocket connection is open.
71
78
  #
72
79
  # @param [Integer] timeout The timeout duration in seconds.
@@ -89,6 +89,8 @@ module Bidi2pdf
89
89
  end
90
90
  when Array
91
91
  obj.map { |item| redact_sensitive_fields(item, sensitive_keys) }
92
+ when String
93
+ Bidi2pdf.truncate_for_log(obj)
92
94
  else
93
95
  obj
94
96
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bidi2pdf
4
+ module Bidi
5
+ module Commands
6
+ class CaptureScreenshot
7
+ include Base
8
+
9
+ attr_reader :context, :origin, :format, :clip
10
+
11
+ def initialize(context:, origin: "document", format: nil, clip: nil)
12
+ @context = context
13
+ @origin = origin
14
+ @format = format
15
+ @clip = clip
16
+ end
17
+
18
+ def method_name
19
+ "browsingContext.captureScreenshot"
20
+ end
21
+
22
+ def params
23
+ {
24
+ context: context,
25
+ origin: origin,
26
+ format: format,
27
+ clip: clip
28
+ }.compact
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bidi2pdf
4
+ module Bidi
5
+ module Commands
6
+ class SetViewport
7
+ include Base
8
+
9
+ attr_reader :context, :width, :height, :device_pixel_ratio
10
+
11
+ def initialize(context:, width:, height:, device_pixel_ratio: nil)
12
+ @context = context
13
+ @width = width
14
+ @height = height
15
+ @device_pixel_ratio = device_pixel_ratio
16
+ end
17
+
18
+ def method_name
19
+ "browsingContext.setViewport"
20
+ end
21
+
22
+ def params
23
+ {
24
+ context: context,
25
+ viewport: { width: width, height: height },
26
+ devicePixelRatio: device_pixel_ratio
27
+ }.compact
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -18,6 +18,8 @@ module Bidi2pdf
18
18
  require_relative "commands/browsing_context_close"
19
19
  require_relative "commands/browsing_context_navigate"
20
20
  require_relative "commands/browsing_context_print"
21
+ require_relative "commands/capture_screenshot"
22
+ require_relative "commands/set_viewport"
21
23
  require_relative "commands/cdp_get_session"
22
24
  require_relative "commands/page_print"
23
25
  require_relative "commands/session_subscribe"
@@ -4,7 +4,7 @@ module Bidi2pdf
4
4
  module Bidi
5
5
  class NetworkEvent
6
6
  attr_reader :id, :url, :state, :start_timestamp, :end_timestamp, :timing, :http_status_code,
7
- :http_method, :bytes_received
7
+ :http_method, :bytes_received, :navigation
8
8
 
9
9
  STATE_MAP = {
10
10
  "network.responseStarted" => "started",
@@ -12,7 +12,13 @@ module Bidi2pdf
12
12
  "network.fetchError" => "error"
13
13
  }.freeze
14
14
 
15
- def initialize(id:, url:, timestamp:, timing:, state:, http_status_code: nil, http_method: nil)
15
+ # @param [String, nil] navigation The BiDi navigation ID this request belongs to - present on
16
+ # the main-frame document request, letting a caller correlate a specific
17
+ # browsingContext.navigate call to the network event carrying its actual HTTP status. A
18
+ # sub-resource request (an image, a script, ...) has no real BiDi navigation ID and stays
19
+ # nil here - #id already distinguishes one event from another in logs, so this field is
20
+ # left alone rather than filled with a value the protocol never sent.
21
+ def initialize(id:, url:, timestamp:, timing:, state:, http_status_code: nil, http_method: nil, navigation: nil)
16
22
  @id = id
17
23
  @url = url
18
24
  @start_timestamp = timestamp
@@ -20,6 +26,7 @@ module Bidi2pdf
20
26
  @state = map_state(state)
21
27
  @http_status_code = http_status_code
22
28
  @http_method = http_method
29
+ @navigation = navigation
23
30
  end
24
31
 
25
32
  def update_state(new_state, timestamp: nil, timing: nil, http_status_code: nil, bytes_received: nil)
@@ -61,6 +68,7 @@ module Bidi2pdf
61
68
  "method=#{method_str.inspect}, " \
62
69
  "url=#{@url.inspect}, " \
63
70
  "state=#{@state.inspect}, " \
71
+ "navigation=#{@navigation.inspect}, " \
64
72
  "#{http_status}, " \
65
73
  "bytes_received=#{bytes_str}, " \
66
74
  "start=#{start_str}, " \
@@ -76,7 +84,8 @@ module Bidi2pdf
76
84
  timing: @timing&.dup,
77
85
  state: @state,
78
86
  http_status_code: @http_status_code,
79
- http_method: @http_method
87
+ http_method: @http_method,
88
+ navigation: @navigation
80
89
  ).tap do |duped|
81
90
  duped.instance_variable_set(:@end_timestamp, @end_timestamp)
82
91
  duped.instance_variable_set(:@bytes_received, @bytes_received)
@@ -41,6 +41,13 @@ module Bidi2pdf
41
41
  timing = request["timings"]
42
42
  http_method = request["method"]
43
43
 
44
+ # A top-level sibling of "request"/"response" in BiDi's own network.BaseParameters, not
45
+ # nested under either - present for the main-frame document request of a navigation, nil
46
+ # for an unrelated sub-resource fetch (an image, a script, ...). Lets a caller correlate a
47
+ # specific browsingContext.navigate call to the exact network event carrying its real HTTP
48
+ # status, which the navigate command's own response never includes.
49
+ navigation = event["navigation"]
50
+
44
51
  timestamp = event["timestamp"]
45
52
 
46
53
  Bidi2pdf.notification_service.instrument("network_event_received.bidi2pdf",
@@ -57,7 +64,8 @@ module Bidi2pdf
57
64
  timestamp: timestamp,
58
65
  timing: timing,
59
66
  state: method,
60
- http_method: http_method
67
+ http_method: http_method,
68
+ navigation: navigation
61
69
  )
62
70
  elsif events.key?(id)
63
71
  events[id].update_state(method, timestamp: timestamp, timing: timing, http_status_code: http_status_code, bytes_received: bytes_received)
@@ -62,7 +62,7 @@ module Bidi2pdf
62
62
  "--disable-breakpad", # Disable the Breakpad crash-reporting library entirely
63
63
  "--enable-features=PdfOopif", # Enable out-of-process iframe (OOPIF) architecture for PDF rendering
64
64
  "--disable-features=Translate,AcceptCHFrame,MediaRouter,OptimizationHints,ProcessPerSiteUpToMainFrameThreshold,IsolateSandboxedIframes",
65
- "--disable-extensions about:blank"
65
+ "--disable-extensions" # Don't load extensions. One switch per entry: it used to read "--disable-extensions about:blank", which Chrome rejected whole ("Ignoring switch with invalid name").
66
66
  ].freeze
67
67
 
68
68
  # @return [URI] The URI of the session.
@@ -194,13 +194,17 @@ module Bidi2pdf
194
194
  cmd.join(" ")
195
195
  end
196
196
 
197
+ # Chromedriver's own internal log verbosity is independent from this app's - defaults to
198
+ # mirroring Bidi2pdf.logger.level for backward compatibility, but Bidi2pdf.chromedriver_log_level
199
+ # lets a consumer quiet chromedriver's own (often much more verbose, e.g. full BiDi command/
200
+ # response dumps at its own INFO level) output without changing their app's own log level.
197
201
  def chromedriver_log_level
198
- case Bidi2pdf.logger.level
199
- when Logger::DEBUG then "ALL"
200
- when Logger::INFO then "INFO"
201
- when Logger::WARN then "WARNING"
202
- else "SEVERE"
203
- end
202
+ Bidi2pdf.chromedriver_log_level || case Bidi2pdf.logger.level
203
+ when Logger::DEBUG then "ALL"
204
+ when Logger::INFO then "INFO"
205
+ when Logger::WARN then "WARNING"
206
+ else "SEVERE"
207
+ end
204
208
  end
205
209
 
206
210
  def user_data_dir_arg
data/lib/bidi2pdf/cli.rb CHANGED
@@ -40,6 +40,8 @@ module Bidi2pdf
40
40
  option :header, type: :array, default: [], banner: "name=value", desc: "One or more custom headers", aliases: "-H"
41
41
  option :auth, type: :string, banner: "user:pass", desc: "Basic auth credentials", aliases: "-a"
42
42
  option :headless, type: :boolean, default: true, desc: "Run Chrome in headless mode"
43
+ option :no_sandbox, type: :boolean, default: false, desc: "Disable Chrome sandbox (needed inside containers)"
44
+ option :chrome_flag, type: :array, default: [], banner: "--flag[=value]", desc: "Extra Chrome flags appended to defaults", aliases: "--cf"
43
45
  option :port, type: :numeric, default: 0, desc: "Port to run ChromeDriver on (0 = auto)"
44
46
  option :wait_window_loaded,
45
47
  type: :boolean,
@@ -114,6 +116,8 @@ module Bidi2pdf
114
116
  "url" => "https://example.com",
115
117
  "output" => "output.pdf",
116
118
  "headless" => true,
119
+ "no_sandbox" => false,
120
+ "chrome_flag" => [],
117
121
  "print_options" => {
118
122
  "background" => true,
119
123
  "orientation" => "portrait",
@@ -210,6 +214,11 @@ module Bidi2pdf
210
214
  @launcher ||= begin
211
215
  username, password = parse_auth(merged_options[:auth]) if merged_options[:auth]
212
216
 
217
+ chrome_args = Bidi2pdf::Bidi::Session::DEFAULT_CHROME_ARGS.dup
218
+ no_sandbox = merged_options[:no_sandbox] || ENV["DISABLE_CHROME_SANDBOX"] == "true"
219
+ chrome_args << "--no-sandbox" if no_sandbox
220
+ chrome_args.concat(Array(merged_options[:chrome_flag]))
221
+
213
222
  Bidi2pdf::Launcher.new(
214
223
  url: merged_options[:url],
215
224
  inputfile: merged_options[:hmtl_file],
@@ -223,7 +232,8 @@ module Bidi2pdf
223
232
  wait_window_loaded: merged_options[:wait_window_loaded],
224
233
  wait_network_idle: merged_options[:wait_network_idle],
225
234
  print_options: print_options,
226
- network_log_format: merged_options[:network_log_format]
235
+ network_log_format: merged_options[:network_log_format],
236
+ chrome_args: chrome_args
227
237
  )
228
238
  end
229
239
  end
@@ -38,7 +38,8 @@ module Bidi2pdf
38
38
  class Launcher
39
39
  # rubocop:disable Metrics/ParameterLists
40
40
  def initialize(url:, inputfile:, output:, cookies:, headers:, auth:, headless: true, port: 0, wait_window_loaded: false,
41
- wait_network_idle: false, print_options: {}, remote_browser_url: nil, network_log_format: :console)
41
+ wait_network_idle: false, print_options: {}, remote_browser_url: nil, network_log_format: :console,
42
+ chrome_args: Bidi::Session::DEFAULT_CHROME_ARGS)
42
43
  @url = url
43
44
  @inputfile = inputfile
44
45
  @port = port
@@ -54,6 +55,7 @@ module Bidi2pdf
54
55
  @remote_browser_url = remote_browser_url
55
56
  @custom_session = nil
56
57
  @network_log_format = network_log_format
58
+ @chrome_args = chrome_args
57
59
  end
58
60
 
59
61
  # rubocop:enable Metrics/ParameterLists
@@ -84,9 +86,9 @@ module Bidi2pdf
84
86
 
85
87
  def session
86
88
  if @remote_browser_url
87
- @custom_session = Bidi::Session.new(session_url: @remote_browser_url, headless: @headless)
89
+ @custom_session = Bidi::Session.new(session_url: @remote_browser_url, headless: @headless, chrome_args: @chrome_args)
88
90
  else
89
- @manager = ChromedriverManager.new(port: @port, headless: @headless)
91
+ @manager = ChromedriverManager.new(port: @port, headless: @headless, chrome_args: @chrome_args)
90
92
  @manager.start
91
93
  @manager.session
92
94
  end
@@ -95,6 +95,8 @@ module Bidi2pdf
95
95
  end
96
96
  when Array
97
97
  obj.map { |item| redact_sensitive_fields(item, sensitive_keys) }
98
+ when String
99
+ Bidi2pdf.truncate_for_log(obj)
98
100
  else
99
101
  obj
100
102
  end