bidi2pdf 0.1.14 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 57c77db5483e79f4cfb2314b792fa10eecf910ee319319c8e838cce0c14dc390
4
- data.tar.gz: '029c81071a16b9c8b276afda6e162811552c095dac134d3f564497f1847840a0'
3
+ metadata.gz: 36afd0d1f8c64ff6d72a8051785d3f65a7efcb336c07e1a6756b772ff4f17414
4
+ data.tar.gz: 6ecddfbacf09de3757b65009e1c41ca3cb90b8667d2fe80972200159d9f0c2b3
5
5
  SHA512:
6
- metadata.gz: 2eabb11610a39980898f8f66109cc61561e47058815283d7f3db711a63ddf7e88b6bb57829dc2e39cb4f0c29811086986ff27b09c3655c8b4c1281dbfa91e1c8
7
- data.tar.gz: e7d8d0099c195d503593cd45fb7a62d1f94b67fd05bba6ef64181e6d239fed23fa0a9f5f934430e1b822389ce30b294f8805f0947f85dbbdfced6e4c71a5d95b
6
+ metadata.gz: '0490c1e9e84467c4437eaf65128cf9adddac4509a66e7e41a791f10cb5fee6ee86d5fb821177af022a04a41441c6c57a37e8015e6a288c5e6838ecbc966bb628'
7
+ data.tar.gz: 935185356734aded9f38453cb2e6340501d1f99c482ffc17d62201867dd95224fe7018dfc079cfbdeed72ea44c63233fb5769987ddaa339ed7425ee6b0e281f5
data/CHANGELOG.md CHANGED
@@ -8,10 +8,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
- [unreleased]: https://github.com/dieter-medium/bidi2pdf/compare/v0.1.14..HEAD
11
+ [unreleased]: https://github.com/dieter-medium/bidi2pdf/compare/v0.1.15..HEAD
12
12
 
13
13
  <!-- generated by git-cliff end -->
14
14
 
15
+ ## [0.1.15] - 2026-09-20
16
+
17
+ ### 🎨 Refactored
18
+
19
+ - Remove deprecated benchmarking script
20
+
21
+ ### 🐛 Fixed
22
+
23
+ - Handle partial Chrome session builds and cleanup
24
+ - Ensure consistent use of alias in nginx_url helper
25
+ - Use AtomicFixnum for thread-safe slot counting
26
+ - Refactor Chrome session setup in specs
27
+ - Improve session warmer configuration and timeout handling
28
+ - Decouple chromedriver log level from logger level
29
+ - Remove navigation ID generation for sub-resources
30
+ - Remove redundant timeout error test
31
+ - Improve string truncation for log safety
32
+ - Improve release credentials check for missing keys
33
+ - Enhance WebSocket handling for control frames and errors
34
+ - Add chromedriver log level configuration
35
+ - Adjust benchmark thresholds for CI environments
36
+ - Add string truncation for safe log output
37
+ - Set timeout for RSpec test runs
38
+ - Prevent logging raw data URLs during navigation
39
+ - Generate unique navigation IDs for sub-resource requests
40
+
41
+ ### 🔄 Changed
42
+
43
+ - Merge pull request #124 from dieter-medium/feat/session-warmer
44
+ - Merge pull request #123 from dieter-medium/feat/faster-pdf-generation
45
+ - Merge pull request #122 from dieter-medium/chore-prepare-dev
46
+ - Bump version to 0.1.15.pre
47
+
48
+ ### 🚀 Added
49
+
50
+ - Implement session warmer idle time management
51
+ - Improve session warmer threading and recovery
52
+ - Enhance session warming and WebSocket health checks
53
+ - Add session warmer and enhance version resolver error handling
54
+ - Add session warming and programmatic configuration
55
+ - Add chromedriver log level mapping and tests
56
+ - Add release credential check for rubygems.org
57
+ - Add navigation id handling in network events
58
+ - Replace WebSocket client library for performance
59
+ - Implement BufferedWebSocketClient and benchmarks
60
+
15
61
  ## [0.1.14] - 2026-09-19
16
62
 
17
63
  ### 🐛 Fixed
@@ -407,7 +453,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
407
453
 
408
454
  ### 🔄 Released
409
455
 
410
- - [unreleased](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.14..HEAD)
456
+ - [unreleased](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.15..HEAD)
457
+ - [0.1.15](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.14..v0.1.15)
411
458
  - [0.1.14](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.13..v0.1.14)
412
459
  - [0.1.13](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.12..v0.1.13)
413
460
  - [0.1.12](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.11..v0.1.12)
data/README.md CHANGED
@@ -24,11 +24,12 @@ Bidi2pdf gives you **precision, flexibility, and full control**.
24
24
  7. [Architecture](#architecture)
25
25
  8. [Docker](#docker)
26
26
  9. [Configuration Options](#configuration-options)
27
- 10. [Rails Integration](#rails-integration)
28
- 11. [Test Helpers](#test-helpers)
29
- 12. [Development](#development)
30
- 13. [Contributing](#contributing)
31
- 14. [License](#license)
27
+ 10. [Programmatic Configuration](#programmatic-configuration)
28
+ 11. [Rails Integration](#rails-integration)
29
+ 12. [Test Helpers](#test-helpers)
30
+ 13. [Development](#development)
31
+ 14. [Contributing](#contributing)
32
+ 15. [License](#license)
32
33
 
33
34
  ## ✨ Key Features
34
35
 
@@ -334,6 +335,68 @@ docker compose -f docker/docker-compose.yml down
334
335
 
335
336
  ---
336
337
 
338
+ ## 🔧 Programmatic Configuration
339
+
340
+ Beyond the per-render CLI flags above, a few gem-wide defaults are set once via `Bidi2pdf.configure`:
341
+
342
+ ```ruby
343
+ Bidi2pdf.configure do |config|
344
+ config.default_timeout = 60 # seconds - default BiDi command timeout
345
+ config.enable_default_logging_subscriber = true
346
+ config.log_truncate_limit = 200 # bytes - see below
347
+ config.chromedriver_log_level = "WARNING" # see below
348
+ end
349
+ ```
350
+
351
+ | Setting | Default | Description |
352
+ |-------------------------------------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
353
+ | `default_timeout` | `60` | Default timeout (seconds) for BiDi commands that don't specify their own. |
354
+ | `enable_default_logging_subscriber` | `true` | Subscribes a default logger to the gem's internal instrumentation events. |
355
+ | `log_truncate_limit` | `200` | Max bytes kept when logging a value that can be large (e.g. a `data:` URL) - truncated at a byte, not character, boundary. |
356
+ | `chromedriver_log_level` | `nil` | ChromeDriver's own `--log-level` (`"ALL"`/`"INFO"`/`"WARNING"`/`"SEVERE"`). Unset mirrors `Bidi2pdf.logger.level`; set explicitly to quiet ChromeDriver's own (often very verbose) output independently of your app's log level. |
357
+
358
+ `Bidi2pdf.logger`, `Bidi2pdf.network_events_logger`, `Bidi2pdf.browser_console_logger`, and
359
+ `Bidi2pdf.notification_service` are also configurable in the same block, for more advanced
360
+ logging/instrumentation needs.
361
+
362
+ ### Pre-warmed sessions (`Bidi2pdf::SessionWarmer`)
363
+
364
+ Optional. Keeps a few Chrome sessions started in the background so a render skips browser startup.
365
+ Every slot is still used for exactly one render and then discarded - isolation is the same as
366
+ launching a fresh Chrome per PDF.
367
+
368
+ ```ruby
369
+ Bidi2pdf::SessionWarmer.configure do |c|
370
+ # warms c.size sessions right here, e.g. at boot
371
+ c.size = 2
372
+ c.max_idle_age = 300
373
+ # c.remote_browser_url = "http://remote-chrome:3000/session"
374
+ end
375
+
376
+ Bidi2pdf::SessionWarmer.with_tab do |tab|
377
+ tab.navigate_to(url)
378
+ tab.print("invoice.pdf")
379
+ end
380
+
381
+ Bidi2pdf::SessionWarmer.shutdown
382
+ ```
383
+
384
+ | Setting | Default | Description |
385
+ |----------------------|-----------------------|-----------------------------------------------------------------------------------------------------------|
386
+ | `size` | `1` | Number of sessions kept warm. With none ready, a render starts its own session as usual - it never waits. |
387
+ | `max_idle_age` | `300` | Seconds a warm session may sit unused before it is retired and replaced. `nil` disables the limit. |
388
+ | `headless` | `true` | Run Chrome headless. |
389
+ | `chrome_args` | `DEFAULT_CHROME_ARGS` | Chrome launch arguments. |
390
+ | `remote_browser_url` | `nil` | Connect each slot to a remote chromedriver instead of starting a local one. |
391
+
392
+ > **Security note:** an idle warm session is an open, unauthenticated automation endpoint
393
+ > (chromedriver's port, Chrome's debugging port - loopback only for a local chromedriver) for as
394
+ > long as it waits. `max_idle_age` bounds that window; keep it set unless the process runs somewhere
395
+ > nothing else can reach those ports. With `remote_browser_url`, who can reach that endpoint on the
396
+ > network is what matters, exactly as it does without the warmer.
397
+
398
+ ---
399
+
337
400
  ## 🚂 Rails Integration
338
401
 
339
402
  Rails integration is available as an additional gem:
@@ -533,11 +533,21 @@ module Bidi2pdf
533
533
 
534
534
  cmd = Bidi2pdf::Bidi::Commands::BrowsingContextNavigate.new url: url, context: browsing_context_id, wait: wait
535
535
 
536
- client.send_cmd_and_wait(cmd) do |response|
537
- 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
538
542
  end
543
+
544
+ check_navigation_http_status(url, navigation_id)
539
545
  rescue Bidi2pdf::CmdError => e
540
- 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"]
541
551
  case msg
542
552
  when /^net::ERR_INVALID_AUTH_CREDENTIALS/
543
553
  raise NavigationAuthError.new(url, msg)
@@ -546,10 +556,41 @@ module Bidi2pdf
546
556
  when /^net::/
547
557
  raise NavigationError, "Connection error: #{url} #{msg}"
548
558
  else
549
- raise e
559
+ raise error
550
560
  end
551
561
  end
552
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
+
553
594
  def register_event_listeners
554
595
  return if @event_handlers_registered
555
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
@@ -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
@@ -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