bidi2pdf 0.1.12 → 0.1.14

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 (116) 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/.rubocop.yml +1 -1
  6. data/.ruby-gemset +1 -1
  7. data/.ruby-version +1 -1
  8. data/CHANGELOG.md +115 -4
  9. data/README.md +12 -0
  10. data/docker/Dockerfile +2 -2
  11. data/docker/Dockerfile.slim +2 -2
  12. data/docker/chrome-driver/chrome.json +874 -0
  13. data/docker/entrypoint.sh +3 -3
  14. data/lib/bidi2pdf/bidi/browser_tab.rb +52 -0
  15. data/lib/bidi2pdf/bidi/commands/capture_screenshot.rb +33 -0
  16. data/lib/bidi2pdf/bidi/commands/set_viewport.rb +32 -0
  17. data/lib/bidi2pdf/bidi/commands.rb +2 -0
  18. data/lib/bidi2pdf/bidi/event_manager.rb +2 -2
  19. data/lib/bidi2pdf/chromedriver_manager.rb +11 -0
  20. data/lib/bidi2pdf/cli.rb +11 -1
  21. data/lib/bidi2pdf/launcher.rb +5 -3
  22. data/lib/bidi2pdf/process_tree.rb +2 -2
  23. data/lib/bidi2pdf/test_helpers/images.rb +8 -0
  24. data/lib/bidi2pdf/test_helpers/pdf_file_helper.rb +5 -1
  25. data/lib/bidi2pdf/test_helpers/pdf_reader_utils.rb +1 -2
  26. data/lib/bidi2pdf/test_helpers/pdf_text_sanitizer.rb +29 -109
  27. data/lib/bidi2pdf/test_helpers/testcontainers/chromedriver_container.rb +1 -1
  28. data/lib/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rb +46 -13
  29. data/lib/bidi2pdf/test_helpers/testcontainers/container_endpoint.rb +30 -0
  30. data/lib/bidi2pdf/test_helpers/testcontainers/shared_docker_network.rb +5 -1
  31. data/lib/bidi2pdf/test_helpers/testcontainers/testcontainers_refinement.rb +33 -0
  32. data/lib/bidi2pdf/test_helpers/testcontainers.rb +1 -0
  33. data/lib/bidi2pdf/version.rb +1 -1
  34. data/lib/bidi2pdf.rb +2 -0
  35. data/sig/bidi2pdf/bidi/add_headers_interceptor.rbs +16 -9
  36. data/sig/bidi2pdf/bidi/auth_interceptor.rbs +32 -7
  37. data/sig/bidi2pdf/bidi/browser.rbs +4 -31
  38. data/sig/bidi2pdf/bidi/browser_console_logger.rbs +59 -0
  39. data/sig/bidi2pdf/bidi/browser_tab.rbs +252 -25
  40. data/sig/bidi2pdf/bidi/client.rbs +95 -43
  41. data/sig/bidi2pdf/bidi/command_manager.rbs +5 -17
  42. data/sig/bidi2pdf/bidi/commands/add_intercept.rbs +16 -8
  43. data/sig/bidi2pdf/bidi/commands/base.rbs +49 -9
  44. data/sig/bidi2pdf/bidi/commands/browser_close.rbs +3 -1
  45. data/sig/bidi2pdf/bidi/commands/browser_create_user_context.rbs +2 -0
  46. data/sig/bidi2pdf/bidi/commands/browser_remove_user_context.rbs +19 -0
  47. data/sig/bidi2pdf/bidi/commands/browsing_context_close.rbs +8 -2
  48. data/sig/bidi2pdf/bidi/commands/browsing_context_navigate.rbs +12 -6
  49. data/sig/bidi2pdf/bidi/commands/browsing_context_print.rbs +10 -5
  50. data/sig/bidi2pdf/bidi/commands/cancel_auth.rbs +8 -2
  51. data/sig/bidi2pdf/bidi/commands/capture_screenshot.rbs +31 -0
  52. data/sig/bidi2pdf/bidi/commands/cdp_get_session.rbs +17 -0
  53. data/sig/bidi2pdf/bidi/commands/create_window.rbs +4 -0
  54. data/sig/bidi2pdf/bidi/commands/get_user_contexts.rbs +2 -0
  55. data/sig/bidi2pdf/bidi/commands/network_continue.rbs +4 -0
  56. data/sig/bidi2pdf/bidi/commands/page_print.rbs +28 -0
  57. data/sig/bidi2pdf/bidi/commands/print_parameters_validator.rbs +3 -0
  58. data/sig/bidi2pdf/bidi/commands/provide_credentials.rbs +12 -6
  59. data/sig/bidi2pdf/bidi/commands/script_evaluate.rbs +4 -0
  60. data/sig/bidi2pdf/bidi/commands/session_end.rbs +2 -0
  61. data/sig/bidi2pdf/bidi/commands/session_status.rbs +3 -1
  62. data/sig/bidi2pdf/bidi/commands/session_subscribe.rbs +4 -0
  63. data/sig/bidi2pdf/bidi/commands/set_tab_cookie.rbs +51 -23
  64. data/sig/bidi2pdf/bidi/commands/set_usercontext_cookie.rbs +17 -19
  65. data/sig/bidi2pdf/bidi/commands/set_viewport.rbs +31 -0
  66. data/sig/bidi2pdf/bidi/connection_manager.rbs +2 -1
  67. data/sig/bidi2pdf/bidi/event_manager.rbs +14 -19
  68. data/sig/bidi2pdf/bidi/interceptor.rbs +5 -0
  69. data/sig/bidi2pdf/bidi/js_logger_helper.rbs +9 -0
  70. data/sig/bidi2pdf/bidi/logger_events.rbs +21 -0
  71. data/sig/bidi2pdf/bidi/navigation_failed_events.rbs +17 -0
  72. data/sig/bidi2pdf/bidi/network_event.rbs +54 -46
  73. data/sig/bidi2pdf/bidi/network_event_formatters/network_event_console_formatter.rbs +63 -0
  74. data/sig/bidi2pdf/bidi/network_event_formatters/network_event_formatter_utils.rbs +15 -0
  75. data/sig/bidi2pdf/bidi/network_event_formatters/network_event_html_formatter.rbs +18 -0
  76. data/sig/bidi2pdf/bidi/network_event_formatters.rbs +6 -0
  77. data/sig/bidi2pdf/bidi/network_events.rbs +14 -39
  78. data/sig/bidi2pdf/bidi/session.rbs +144 -31
  79. data/sig/bidi2pdf/bidi/user_context.rbs +64 -45
  80. data/sig/bidi2pdf/bidi/web_socket_dispatcher.rbs +22 -30
  81. data/sig/bidi2pdf/chromedriver_manager.rbs +57 -22
  82. data/sig/bidi2pdf/cli.rbs +22 -2
  83. data/sig/bidi2pdf/dsl.rbs +34 -0
  84. data/sig/bidi2pdf/launcher.rbs +39 -1
  85. data/sig/bidi2pdf/notifications/event.rbs +40 -0
  86. data/sig/bidi2pdf/notifications/instrumenter.rbs +21 -0
  87. data/sig/bidi2pdf/notifications/logging_subscriber.rbs +41 -0
  88. data/sig/bidi2pdf/notifications.rbs +24 -0
  89. data/sig/bidi2pdf/process_tree.rbs +2 -2
  90. data/sig/bidi2pdf/session_runner.rbs +41 -1
  91. data/sig/bidi2pdf/test_helpers/configuration.rbs +51 -0
  92. data/sig/bidi2pdf/test_helpers/images/extractor.rbs +43 -0
  93. data/sig/bidi2pdf/test_helpers/images/image_similarity_checker.rbs +35 -0
  94. data/sig/bidi2pdf/test_helpers/images/tiff_helper.rbs +70 -0
  95. data/sig/bidi2pdf/test_helpers/images.rbs +0 -0
  96. data/sig/bidi2pdf/test_helpers/matchers/contains_pdf_image.rbs +0 -0
  97. data/sig/bidi2pdf/test_helpers/matchers/contains_pdf_text.rbs +0 -0
  98. data/sig/bidi2pdf/test_helpers/matchers/have_pdf_page_count.rbs +0 -0
  99. data/sig/bidi2pdf/test_helpers/matchers/match_pdf_text.rbs +0 -0
  100. data/sig/bidi2pdf/test_helpers/pdf_file_helper.rbs +23 -0
  101. data/sig/bidi2pdf/test_helpers/pdf_reader_utils.rbs +49 -0
  102. data/sig/bidi2pdf/test_helpers/pdf_text_sanitizer.rbs +38 -0
  103. data/sig/bidi2pdf/test_helpers/spec_paths_helper.rbs +35 -0
  104. data/sig/bidi2pdf/test_helpers/testcontainers/chromedriver_container.rbs +43 -0
  105. data/sig/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rbs +45 -0
  106. data/sig/bidi2pdf/test_helpers/testcontainers/container_endpoint.rbs +25 -0
  107. data/sig/bidi2pdf/test_helpers/testcontainers/shared_docker_network.rbs +3 -0
  108. data/sig/bidi2pdf/test_helpers/testcontainers/testcontainers_refinement.rbs +47 -0
  109. data/sig/bidi2pdf/test_helpers/testcontainers.rbs +6 -0
  110. data/sig/bidi2pdf/test_helpers.rbs +0 -0
  111. data/sig/bidi2pdf/verbose_logger.rbs +37 -0
  112. data/sig/bidi2pdf/version.rbs +3 -0
  113. data/sig/bidi2pdf.rbs +112 -2
  114. data/tasks/generate_rbs.rake +11 -11
  115. metadata +62 -10
  116. data/sig/bidi2pdf/utils.rbs +0 -5
data/docker/entrypoint.sh CHANGED
@@ -45,10 +45,10 @@ fi
45
45
 
46
46
  # DISPLAY=:99 /home/appuser/.webdrivers/chromedriver --port=33259 --whitelisted-ips="" --allowed-origins="*" --disable-dev-shm-usage --disable-gpu --verbose
47
47
  /home/appuser/.webdrivers/chromedriver --port=${CHROMEDRIVER_PORT} \
48
- --headless \
49
- --whitelisted-ips="" \
48
+ --allowed-ips="" \
50
49
  --allowed-origins="*" \
51
50
  --disable-dev-shm-usage \
52
51
  --disable-gpu \
53
52
  --user-data-dir=${USER_DATA_DIR} \
54
- --verbose
53
+ --log-level=${CHROMEDRIVER_LOG_LEVEL:-INFO} \
54
+ --readable-timestamp
@@ -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)
@@ -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"
@@ -9,8 +9,8 @@ module Bidi2pdf
9
9
  self.source_location = block.source_location
10
10
  end
11
11
 
12
- def call(*args)
13
- block.call(*args)
12
+ def call(*)
13
+ block.call(*)
14
14
  end
15
15
 
16
16
  def ==(other)
@@ -185,6 +185,8 @@ module Bidi2pdf
185
185
  cmd = [bin]
186
186
  cmd << "--port=#{@port}" unless @port.zero?
187
187
  cmd << "--headless" if @headless
188
+ cmd << "--log-level=#{chromedriver_log_level}"
189
+ cmd << "--readable-timestamp"
188
190
 
189
191
  cmd << user_data_dir_arg
190
192
  cmd.concat(process_chrome_args)
@@ -192,6 +194,15 @@ module Bidi2pdf
192
194
  cmd.join(" ")
193
195
  end
194
196
 
197
+ 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
204
+ end
205
+
195
206
  def user_data_dir_arg
196
207
  provided = @chrome_args&.find { |arg| arg.include?("--user-data-dir") }
197
208
  return provided if provided
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
@@ -36,8 +36,8 @@ module Bidi2pdf
36
36
  end
37
37
 
38
38
  def build_process_map
39
- ProcTable.ps.each_with_object({}) do |process, map|
40
- map[process.pid] = { info: process, children: [] }
39
+ ProcTable.ps.to_h do |process|
40
+ [process.pid, { info: process, children: [] }]
41
41
  end
42
42
  end
43
43
 
@@ -6,6 +6,14 @@ rescue LoadError
6
6
  warn "Missing #{dep}. Add it to your Gemfile if you're using Bidi2pdf image test helpers."
7
7
  end
8
8
 
9
+ # CVE-2026-66066 (Rails Active Storage, 2026-07-29): libvips ships loaders/savers that were never
10
+ # fuzz-tested (SVG, JPEG XL, JPEG 2000, BMP, ICO, PSD, anything delegated to ImageMagick, ...) -
11
+ # left reachable, a crafted file can trigger arbitrary file read or RCE. Applied once, here, for
12
+ # every consumer of this opt-in module (Extractor, ImageSimilarityChecker, contains_pdf_image),
13
+ # rather than left to each call site to remember - PNG/JPEG (what this gem's own screenshot/PDF
14
+ # rendering actually produces) load through already-fuzzed loaders and are unaffected.
15
+ Vips.block_untrusted(true) if defined?(Vips)
16
+
9
17
  require_relative "images/tiff_helper"
10
18
  require_relative "images/extractor"
11
19
  require_relative "images/image_similarity_checker"
@@ -5,6 +5,10 @@ module Bidi2pdf
5
5
  # This module provides helper methods for handling PDF files in tests.
6
6
  # It includes methods for debugging, storing, and managing PDF files.
7
7
  module PdfFileHelper
8
+ def reporter
9
+ RSpec.configuration.reporter
10
+ end
11
+
8
12
  # Executes a block with the given PDF data and handles debugging in case of test failures.
9
13
  # If an expectation fails, the PDF data is saved to a file for debugging purposes.
10
14
  # @param [String] pdf_data the PDF data to debug
@@ -14,7 +18,7 @@ module Bidi2pdf
14
18
  yield pdf_data
15
19
  rescue RSpec::Expectations::ExpectationNotMetError => e
16
20
  failure_output = store_pdf_file pdf_data, "test-failure"
17
- puts "Test failed! PDF saved to: #{failure_output}"
21
+ reporter.message("Test failed! PDF saved to: #{failure_output}")
18
22
  raise e
19
23
  end
20
24
 
@@ -49,7 +49,7 @@ module Bidi2pdf
49
49
  # @param pdf_data [String, StringIO, File] The PDF data to be converted.
50
50
  # @return [IO] An IO object containing the PDF data.
51
51
  def convert_data_to_io(pdf_data)
52
- # rubocop:disable Lint/DuplicateBranch
52
+ # rubocop:disable-next Lint/DuplicateBranch
53
53
  if pdf_data.is_a?(String) && (pdf_data.start_with?("JVBERi") || pdf_data.start_with?("JVBER"))
54
54
  StringIO.new(Base64.decode64(pdf_data))
55
55
  elsif pdf_data.start_with?("%PDF-")
@@ -61,7 +61,6 @@ module Bidi2pdf
61
61
  else
62
62
  StringIO.new(pdf_data)
63
63
  end
64
- # rubocop:enable Lint/DuplicateBranch
65
64
  end
66
65
  end
67
66
 
@@ -6,26 +6,9 @@ require "diff/lcs/hunk"
6
6
 
7
7
  module Bidi2pdf
8
8
  module TestHelpers
9
- # rubocop: disable Metrics/ModuleLength
10
- # Provides utilities for sanitizing and comparing PDF text content.
11
- # This module includes methods for cleaning text, comparing PDF content,
12
- # and reporting differences between actual and expected PDF outputs.
13
- #
14
- # The sanitization process includes normalizing whitespace, replacing
15
- # typographic ligatures, and handling other common text formatting issues.
16
- #
17
- # @example Cleaning text
18
- # sanitized_text = Bidi2pdf::TestHelpers::PDFTextSanitizer.clean("Some text")
19
- #
20
- # @example Comparing PDF content
21
- # match = Bidi2pdf::TestHelpers::PDFTextSanitizer.match?(actual_pdf, expected_pdf)
9
+ # rubocop: disable-next Metrics/ModuleLength
22
10
  module PDFTextSanitizer
23
11
  class << self
24
- # Cleans the given text by replacing common typographic ligatures,
25
- # normalizing whitespace, and removing unnecessary characters.
26
- #
27
- # @param [String] text The text to clean.
28
- # @return [String] The cleaned text.
29
12
  def clean(text)
30
13
  text = UnicodeUtils.nfkd(text)
31
14
 
@@ -37,157 +20,96 @@ module Bidi2pdf
37
20
  .gsub("…", "...")
38
21
  .gsub("—", "--")
39
22
  .gsub("–", "-")
40
- .gsub(/\s+/, " ") # Replace all whitespace sequences with a single space
23
+ .gsub(/\s+/, " ")
41
24
  .strip
42
25
  end
43
26
 
44
- # Cleans an array of PDF page texts by applying the `clean` method
45
- # to each page's content.
46
- #
47
- # @param [Object] actual_pdf_thingy The PDF object to clean.
48
- # @return [Array<String>] An array of cleaned page texts.
49
- def clean_pages(actual_pdf_thingy)
50
- Bidi2pdf::TestHelpers::PDFReaderUtils.pdf_text(actual_pdf_thingy).map { |text| clean(text) }
27
+ def clean_pages(pdf)
28
+ Bidi2pdf::TestHelpers::PDFReaderUtils.pdf_text(pdf).map { |text| clean(text) }
51
29
  end
52
30
 
53
- # Cleans the given text and removes all whitespace for comparison purposes.
54
- #
55
- # @param [String] text The text to clean and normalize.
56
- # @return [String] The cleaned text without whitespace.
57
31
  def normalize(text)
58
32
  clean(text).gsub(/\s+/, "")
59
33
  end
60
34
 
61
- # Checks if the given PDF contains the expected text or pattern.
62
- #
63
- # @param [Object] actual_pdf_thingy The PDF object to search.
64
- # @param [String, Regexp] expected The expected text or pattern.
65
- # @param [Integer, nil] page_number The specific page to search (optional).
66
- # @return [Boolean] `true` if the expected text is found, `false` otherwise.
67
35
  def contains?(actual_pdf_thingy, expected, page_number = nil)
68
- pages = Bidi2pdf::TestHelpers::PDFReaderUtils.pdf_text(actual_pdf_thingy)
69
- cleaned_pages = clean_pages(pages)
36
+ cleaned_pages = clean_pages(actual_pdf_thingy)
70
37
 
71
38
  return false if page_number && page_number > cleaned_pages.size
72
39
 
73
- # Narrow to specific page if requested
74
40
  if page_number
75
41
  text = cleaned_pages[page_number - 1]
76
42
  return match_expected?(text, expected)
77
43
  end
78
44
 
79
- # Search all pages
80
45
  cleaned_pages.any? { |page| match_expected?(page, expected) }
81
46
  end
82
47
 
83
- # Matches the given text against the expected text or pattern.
84
- #
85
- # @param [String] text The text to match.
86
- # @param [String, Regexp] expected The expected text or pattern.
87
- # @return [Boolean] `true` if the text matches, `false` otherwise.
88
48
  def match_expected?(text, expected)
89
49
  return false unless text
90
50
 
91
51
  expected.is_a?(Regexp) ? text.match?(expected) : text.include?(expected.to_s)
92
52
  end
93
53
 
94
- # Compares the content of two PDF objects for equality.
95
- #
96
- # @param [Object] actual_pdf_thingy The actual PDF object.
97
- # @param [Object] expected_pdf_thingy The expected PDF object.
98
- # @return [Boolean] `true` if the content matches, `false` otherwise.
99
54
  def match?(actual_pdf_thingy, expected_pdf_thingy)
100
- actual = Bidi2pdf::TestHelpers::PDFReaderUtils.pdf_text actual_pdf_thingy
101
- expected = Bidi2pdf::TestHelpers::PDFReaderUtils.pdf_text expected_pdf_thingy
55
+ cleaned_actual = clean_pages(actual_pdf_thingy)
56
+ cleaned_expected = clean_pages(expected_pdf_thingy)
102
57
 
103
- cleaned_actual = clean_pages(actual)
104
- cleaned_expected = clean_pages(expected)
105
-
106
- # Compare without whitespace for equality check
107
58
  actual_for_comparison = cleaned_actual.map { |text| normalize(text) }
108
59
  expected_for_comparison = cleaned_expected.map { |text| normalize(text) }
109
60
 
110
- if actual_for_comparison == expected_for_comparison
111
- true
112
- else
113
- report_content_mismatch(cleaned_actual, cleaned_expected)
114
- false
115
- end
61
+ return true if actual_for_comparison == expected_for_comparison
62
+
63
+ report_content_mismatch(cleaned_actual, cleaned_expected)
64
+ false
116
65
  end
117
66
 
118
- # Reports differences between actual and expected PDF content.
119
- #
120
- # @param [Array<String>] actual The actual PDF content.
121
- # @param [Array<String>] expected The expected PDF content.
122
- # @return [void]
123
67
  def report_content_mismatch(actual, expected)
124
- puts "--- PDF content mismatch ---"
125
- print_differences(actual, expected)
68
+ reporter.message(content_mismatch_message(actual, expected))
126
69
  end
127
70
 
128
- # Prints detailed differences between actual and expected PDF content.
129
- #
130
- # @param [Array<String>] actual The actual PDF content.
131
- # @param [Array<String>] expected The expected PDF content.
132
- # @return [void]
133
- def print_differences(actual, expected)
71
+ def content_mismatch_message(actual, expected)
72
+ sections = ["--- PDF content mismatch ---"]
134
73
  max_pages = [actual.length, expected.length].max
135
74
 
136
75
  (0...max_pages).each do |page_idx|
137
76
  actual_page = actual[page_idx] || "(missing page)"
138
77
  expected_page = expected[page_idx] || "(missing page)"
139
78
 
140
- print_differences_for_page(actual_page, expected_page, page_idx)
79
+ page_message = page_difference_message(actual_page, expected_page, page_idx)
80
+ sections << page_message if page_message
141
81
  end
82
+
83
+ sections.join("\n")
142
84
  end
143
85
 
144
- # Prints the differences between actual and expected content for a specific page.
145
- # This method compares the content ignoring whitespace and, if differences are found,
146
- # outputs a formatted representation of those differences.
147
- #
148
- # @param [String] actual_page The actual page content.
149
- # @param [String] expected_page The expected page content.
150
- # @param [Integer] page_idx The zero-based index of the page being compared.
151
- # @return [void]
152
- def print_differences_for_page(actual_page, expected_page, page_idx)
153
- # Compare without whitespace
86
+ def page_difference_message(actual_page, expected_page, page_idx)
154
87
  actual_no_space = normalize(actual_page.to_s)
155
88
  expected_no_space = normalize(expected_page.to_s)
156
89
 
157
- return if actual_no_space == expected_no_space
158
-
159
- puts "\nPage #{page_idx + 1} differences (ignoring whitespace):"
90
+ return nil if actual_no_space == expected_no_space
160
91
 
161
- # Create diffs between the two pages
162
92
  diffs = Diff::LCS.sdiff(expected_page, actual_page)
163
93
 
164
- # Format and display the differences
165
- puts format_diff_output(diffs, expected_page, actual_page)
94
+ [
95
+ "",
96
+ "Page #{page_idx + 1} differences (ignoring whitespace):",
97
+ format_diff_output(diffs, expected_page, actual_page)
98
+ ].join("\n")
166
99
  end
167
100
 
168
- # Formats the output of differences for display.
169
- #
170
- # @param [Array<Diff::LCS::ContextChange>] diffs The list of differences.
171
- # @param [String] expected The expected text.
172
- # @param [String] actual The actual text.
173
- # @return [String] The formatted differences.
174
101
  def format_diff_output(diffs, expected, actual)
175
- output = []
176
-
177
102
  changes = group_changed_diffs(diffs)
178
103
 
179
- # Output each change with context
180
- changes.each do |change|
181
- output += format_change expected, actual, change
182
- end
104
+ changes.flat_map { |change| format_change(expected, actual, change) }.join("\n")
105
+ end
183
106
 
184
- output.join("\n")
107
+ def reporter
108
+ RSpec.configuration.reporter
185
109
  end
186
110
 
187
111
  private
188
112
 
189
- # Groups contiguous “real” diffs (added/removed/changed) into blocks,
190
- # splitting whenever you hit an unchanged (“=”) diff.
191
113
  def group_changed_diffs(diffs)
192
114
  diffs
193
115
  .chunk_while { |_prev, curr| curr.action != "=" }
@@ -212,7 +134,6 @@ module Bidi2pdf
212
134
  }
213
135
  end
214
136
 
215
- # 3. Build the final lines of output
216
137
  def build_output(snip_data, pos)
217
138
  start = snip_data[:context_start]
218
139
  ctx = snip_data[:context]
@@ -227,6 +148,5 @@ module Bidi2pdf
227
148
  end
228
149
  end
229
150
  end
230
- # rubocop:enable Metrics/ModuleLength
231
151
  end
232
152
  end
@@ -73,7 +73,7 @@ module Bidi2pdf
73
73
  # rubocop: enable Metrics/AbcSize
74
74
 
75
75
  def session_url(protocol: "http")
76
- "#{protocol}://#{host}:#{mapped_port(port)}/session"
76
+ ContainerEndpoint.new(self, port).url(scheme: protocol, path: "session")
77
77
  end
78
78
  end
79
79
  end
@@ -16,6 +16,10 @@ module Bidi2pdf
16
16
  end
17
17
 
18
18
  module SessionTestHelper
19
+ def reporter
20
+ RSpec.configuration.reporter
21
+ end
22
+
19
23
  def chrome_args
20
24
  chrome_args = Bidi2pdf::Bidi::Session::DEFAULT_CHROME_ARGS.dup
21
25
 
@@ -24,7 +28,7 @@ module Bidi2pdf
24
28
  if ENV["DISABLE_CHROME_SANDBOX"]
25
29
  chrome_args << "--no-sandbox"
26
30
 
27
- puts "🚨 Chrome sandbox disabled"
31
+ reporter.message("🚨 Chrome sandbox disabled")
28
32
  end
29
33
  chrome_args
30
34
  end
@@ -32,6 +36,24 @@ module Bidi2pdf
32
36
  def create_session(session_url)
33
37
  Bidi2pdf::Bidi::Session.new(session_url: session_url, headless: true, chrome_args: chrome_args)
34
38
  end
39
+
40
+ # Full session/tab lifecycle in one call - create_session above only builds the session
41
+ # object, it never starts or closes it, and there is no after(:each) teardown for a spec
42
+ # that uses it directly. Mirrors Bidi2pdf::DSL.with_tab's own ensure-based teardown, but
43
+ # against a session_url this module already knows about (a long-lived remote-chrome
44
+ # reached over `session:` metadata, not a freshly spawned local chromedriver per call).
45
+ def with_tab(session_url)
46
+ session = create_session(session_url)
47
+ session.start
48
+ user_context = session.browser.create_user_context
49
+ tab = user_context.create_browser_window
50
+
51
+ yield tab
52
+ ensure
53
+ tab&.close
54
+ user_context&.close
55
+ session&.close
56
+ end
35
57
  end
36
58
  end
37
59
  end
@@ -51,7 +73,7 @@ RSpec.configure do |config|
51
73
  shared_network: config.shared_network
52
74
  )
53
75
 
54
- puts "🚀 chromedriver container started for tests"
76
+ reporter.message("🚀 chromedriver container started for tests")
55
77
  end
56
78
  end
57
79
 
@@ -61,20 +83,31 @@ RSpec.configure do |config|
61
83
  end
62
84
 
63
85
  def stop_container(container)
64
- if container&.running?
86
+ return unless container
65
87
 
66
- if ENV["SHOW_CONTAINER_LOGS"]
67
- puts "Container logs:"
68
- logs_std, logs_error = container.logs
88
+ dump_container_logs(container) if container.running?
89
+ stop_running_container(container)
90
+ container.remove
91
+ end
69
92
 
70
- puts logs_error
71
- puts logs_std
72
- end
93
+ def dump_container_logs(container)
94
+ return unless ENV["SHOW_CONTAINER_LOGS"]
73
95
 
74
- puts "🧹 #{container.image} stopping container..."
75
- container.stop
76
- end
77
- container&.remove
96
+ stdout, stderr = container.logs
97
+ reporter.message("Container logs:")
98
+ reporter.message(stderr.to_s) unless stderr.to_s.empty?
99
+ reporter.message(stdout.to_s) unless stdout.to_s.empty?
100
+ end
101
+
102
+ def stop_running_container(container)
103
+ return unless container.running?
104
+
105
+ reporter.message("🧹 #{container.image} stopping container...")
106
+ container.stop
107
+ end
108
+
109
+ def reporter
110
+ RSpec.configuration.reporter
78
111
  end
79
112
 
80
113
  def chromedriver_tests_present?