bidi2pdf 0.1.15 → 0.1.17

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +20 -0
  3. data/CHANGELOG.md +46 -2
  4. data/README.md +233 -11
  5. data/docker/Dockerfile +11 -0
  6. data/docker/Dockerfile.chromedriver +7 -0
  7. data/docker/Dockerfile.slim +12 -1
  8. data/lib/bidi2pdf/bidi/buffered_web_socket_client.rb +9 -0
  9. data/lib/bidi2pdf/bidi/session.rb +5 -1
  10. data/lib/bidi2pdf/cli/json_output.rb +30 -0
  11. data/lib/bidi2pdf/cli.rb +559 -17
  12. data/lib/bidi2pdf/diagnose.rb +119 -0
  13. data/lib/bidi2pdf/error_codes.rb +59 -0
  14. data/lib/bidi2pdf/exit_codes.rb +44 -0
  15. data/lib/bidi2pdf/launcher.rb +23 -0
  16. data/lib/bidi2pdf/manifest.rb +65 -0
  17. data/lib/bidi2pdf/notifications/json_subscriber.rb +78 -0
  18. data/lib/bidi2pdf/pdf_inspection.rb +62 -0
  19. data/lib/bidi2pdf/recipe/loader.rb +44 -0
  20. data/lib/bidi2pdf/recipe/runner.rb +229 -0
  21. data/lib/bidi2pdf/recipe/schema_shape.rb +228 -0
  22. data/lib/bidi2pdf/recipe/validator.rb +138 -0
  23. data/lib/bidi2pdf/recipe.rb +83 -0
  24. data/lib/bidi2pdf/result.rb +67 -0
  25. data/lib/bidi2pdf/result_collector.rb +150 -0
  26. data/lib/bidi2pdf/schema.rb +382 -0
  27. data/lib/bidi2pdf/session_registry.rb +97 -0
  28. data/lib/bidi2pdf/session_runner.rb +42 -0
  29. data/lib/bidi2pdf/session_sweeper.rb +64 -0
  30. data/lib/bidi2pdf/session_warmer.rb +51 -1
  31. data/lib/bidi2pdf/version.rb +1 -1
  32. data/lib/bidi2pdf.rb +92 -8
  33. data/sig/bidi2pdf/bidi/buffered_web_socket_client.rbs +7 -0
  34. data/sig/bidi2pdf/bidi/session.rbs +6 -0
  35. data/sig/bidi2pdf/cli/json_output.rbs +19 -0
  36. data/sig/bidi2pdf/cli.rbs +112 -2
  37. data/sig/bidi2pdf/diagnose.rbs +30 -0
  38. data/sig/bidi2pdf/error_codes.rbs +21 -0
  39. data/sig/bidi2pdf/exit_codes.rbs +21 -0
  40. data/sig/bidi2pdf/launcher.rbs +9 -0
  41. data/sig/bidi2pdf/manifest.rbs +38 -0
  42. data/sig/bidi2pdf/notifications/json_subscriber.rbs +45 -0
  43. data/sig/bidi2pdf/pdf_inspection.rbs +33 -0
  44. data/sig/bidi2pdf/recipe/loader.rbs +20 -0
  45. data/sig/bidi2pdf/recipe/runner.rbs +92 -0
  46. data/sig/bidi2pdf/recipe/schema_shape.rbs +85 -0
  47. data/sig/bidi2pdf/recipe/validator.rbs +54 -0
  48. data/sig/bidi2pdf/recipe.rbs +72 -0
  49. data/sig/bidi2pdf/result.rbs +71 -0
  50. data/sig/bidi2pdf/result_collector.rbs +106 -0
  51. data/sig/bidi2pdf/schema.rbs +45 -0
  52. data/sig/bidi2pdf/session_registry.rbs +44 -0
  53. data/sig/bidi2pdf/session_runner.rbs +17 -0
  54. data/sig/bidi2pdf/session_sweeper.rbs +36 -0
  55. data/sig/bidi2pdf/session_warmer.rbs +33 -0
  56. data/sig/bidi2pdf/version.rbs +1 -1
  57. data/sig/bidi2pdf.rbs +66 -0
  58. metadata +43 -3
data/lib/bidi2pdf/cli.rb CHANGED
@@ -2,10 +2,15 @@
2
2
 
3
3
  require "thor"
4
4
  require "yaml"
5
+ require "tempfile"
6
+
7
+ require_relative "cli/json_output"
5
8
 
6
9
  module Bidi2pdf
7
10
  # rubocop:disable Metrics/AbcSize
8
11
  class CLI < Thor
12
+ include JsonOutput
13
+
9
14
  class_option :config, type: :string, desc: "Load configuration from YAML file"
10
15
 
11
16
  desc "render", "Render a URL to PDF using Chrome BiDi"
@@ -65,6 +70,11 @@ module Bidi2pdf
65
70
  enum: %w[console pdf],
66
71
  desc: "Choose network log format: console or pdf", aliases: "-f"
67
72
 
73
+ option :json, type: :boolean, default: false, desc: "Emit a single structured JSON result document (see `bidi2pdf schema render`) instead of human-readable logs"
74
+ option :json_stream, type: :boolean, default: false, desc: "Emit one JSON progress event per line to stderr as the render happens, with or without --json (see `bidi2pdf schema event`)"
75
+ option :stdin, type: :boolean, default: false, desc: "Read the HTML document to render from stdin, instead of --url/--html-file"
76
+ option :manifest, type: :string, desc: "Write a render manifest (see `bidi2pdf schema manifest`) to this file"
77
+
68
78
  option :background, type: :boolean, default: true, desc: "Print background graphics"
69
79
  option :margin_top, type: :numeric, default: 1.0, desc: "Top margin in inches"
70
80
  option :margin_bottom, type: :numeric, default: 1.0, desc: "Bottom margin in inches"
@@ -88,26 +98,91 @@ module Bidi2pdf
88
98
  def render
89
99
  load_config
90
100
 
91
- validate_required_options!
101
+ if json_mode? && stdout_output?
102
+ reject_conflicting_output_streams!
103
+ return
104
+ end
92
105
 
93
- configure
106
+ json_mode? || stdout_output? ? perform_structured_render : perform_human_render
107
+ end
94
108
 
95
- Bidi2pdf.logger.info "Rendering: #{merged_options[:url]} -> #{merged_options[:output]}"
96
- Bidi2pdf.logger.info "Print options: #{print_options.inspect}" if print_options
109
+ desc "version", "Show bidi2pdf version"
110
+ option :json, type: :boolean, default: false, desc: "Emit version info as JSON instead of a plain string"
97
111
 
98
- validate_print_options(print_options) if print_options
112
+ def version
113
+ if options[:json]
114
+ puts version_info.to_json
115
+ else
116
+ puts "bidi2pdf #{Bidi2pdf::VERSION}"
117
+ end
118
+ end
99
119
 
100
- launcher.launch
101
- ensure
102
- launcher.stop if defined?(@launcher) && @launcher
120
+ desc "schema KIND", "Print the JSON Schema for KIND (render, diagnose, run, manifest, recipe, event)"
121
+
122
+ def schema(kind)
123
+ puts JSON.pretty_generate(Bidi2pdf::Schema.for(kind))
124
+ rescue ArgumentError => e
125
+ raise Thor::Error, e.message
103
126
  end
104
127
 
105
- desc "version", "Show bidi2pdf version"
128
+ desc "diagnose", "Load a page and report render-focused diagnostics (console, network, fonts, print CSS, Paged.js) without producing a PDF"
129
+ option :url, desc: "The URL to load"
130
+ option :html_file, desc: "The local HTML file to load"
131
+ option :stdin, type: :boolean, default: false, desc: "Read the HTML document to load from stdin, instead of --url/--html-file"
132
+ option :cookie, type: :array, default: [], banner: "name=value", desc: "One or more cookies", aliases: "-C"
133
+ option :header, type: :array, default: [], banner: "name=value", desc: "One or more custom headers", aliases: "-H"
134
+ option :auth, type: :string, banner: "user:pass", desc: "Basic auth credentials", aliases: "-a"
135
+ option :headless, type: :boolean, default: true, desc: "Run Chrome in headless mode"
136
+ option :no_sandbox, type: :boolean, default: false, desc: "Disable Chrome sandbox (needed inside containers)"
137
+ option :chrome_flag, type: :array, default: [], banner: "--flag[=value]", desc: "Extra Chrome flags appended to defaults", aliases: "--cf"
138
+ option :port, type: :numeric, default: 0, desc: "Port to run ChromeDriver on (0 = auto)"
139
+ option :wait_window_loaded, type: :boolean, default: false, desc: "Wait for the window to be fully loaded"
140
+ option :wait_network_idle, type: :boolean, default: false, desc: "Wait for network to be idle"
141
+ option :default_timeout, type: :numeric, default: 60, desc: "Default timeout for commands"
142
+ option :remote_browser_url, type: :string, desc: "Remote browser URL for ChromeDriver"
143
+ option :log_level, type: :string, default: "info", enum: %w[debug info warn error fatal unknown], desc: "Set log level"
144
+ option :verbosity,
145
+ type: :string,
146
+ default: Bidi2pdf::VerboseLogger::VERBOSITY_LEVELS.keys.min_by { |k| Bidi2pdf::VerboseLogger::VERBOSITY_LEVELS[k] }.to_s,
147
+ enum: Bidi2pdf::VerboseLogger::VERBOSITY_LEVELS.keys.map(&:to_s),
148
+ desc: "Set debug verbosity level", aliases: "-v"
149
+ option :log_network_traffic, type: :boolean, default: false, desc: "Log network traffic", aliases: "-n"
150
+ option :json, type: :boolean, default: false, desc: "Emit a single structured JSON document (see `bidi2pdf schema diagnose`) instead of a human-readable summary"
151
+ option :json_stream, type: :boolean, default: false, desc: "Emit one JSON progress event per line to stderr as diagnostics run, with or without --json (see `bidi2pdf schema event`)"
152
+ option :screenshot, type: :string, desc: "Also capture a screenshot to this PNG file"
153
+ option :pdf, type: :string, desc: "Also render a PDF to this file and report its page count / blank-check"
106
154
 
107
- def version
108
- puts "bidi2pdf #{Bidi2pdf::VERSION}"
155
+ def diagnose
156
+ load_config
157
+
158
+ json_mode? ? perform_structured_diagnose : perform_human_diagnose
109
159
  end
110
160
 
161
+ desc "run RECIPE", "Run a declarative recipe (see `bidi2pdf schema recipe`)"
162
+ option :validate, type: :boolean, default: false, desc: "Validate the recipe and exit, without launching a browser"
163
+ option :json, type: :boolean, default: false, desc: "Emit a single structured JSON result document (see `bidi2pdf schema run`) instead of a human-readable summary"
164
+ option :json_stream, type: :boolean, default: false, desc: "Emit one JSON progress event per line to stderr as the recipe runs, with or without --json (see `bidi2pdf schema event`)"
165
+ option :headless, type: :boolean, desc: "Override the recipe's browser.headless"
166
+ option :remote_browser_url, type: :string, desc: "Remote browser URL for ChromeDriver"
167
+ option :default_timeout, type: :numeric, default: 60, desc: "Default timeout for commands"
168
+ option :log_level, type: :string, default: "info", enum: %w[debug info warn error fatal unknown], desc: "Set log level"
169
+ option :verbosity,
170
+ type: :string,
171
+ default: Bidi2pdf::VerboseLogger::VERBOSITY_LEVELS.keys.min_by { |k| Bidi2pdf::VerboseLogger::VERBOSITY_LEVELS[k] }.to_s,
172
+ enum: Bidi2pdf::VerboseLogger::VERBOSITY_LEVELS.keys.map(&:to_s),
173
+ desc: "Set debug verbosity level", aliases: "-v"
174
+ option :log_network_traffic, type: :boolean, default: false, desc: "Log network traffic", aliases: "-n"
175
+
176
+ # Named run_recipe, not run: Thor::Base reserves "run" as a method name (Thor::Base::
177
+ # ClassMethods#is_thor_reserved_word?) and refuses to define a command with that name. `map`
178
+ # below is what makes `bidi2pdf run recipe.yml` dispatch here.
179
+ def run_recipe(recipe_path)
180
+ load_config
181
+
182
+ json_mode? ? perform_structured_run(recipe_path) : perform_human_run(recipe_path)
183
+ end
184
+ map "run" => :run_recipe
185
+
111
186
  desc "template", "Generate a config file template"
112
187
  option :output, default: "bidi2pdf.yml", desc: "Output configuration filename"
113
188
 
@@ -136,24 +211,491 @@ module Bidi2pdf
136
211
 
137
212
  private
138
213
 
214
+ # --- render: human mode (default) - unchanged behavior, just wrapping the new typed errors
215
+ # validate_required_options!/validate_print_options now raise back into the historic
216
+ # Thor::Error type/message pair, so every previously-documented CLI behavior stays intact.
217
+
218
+ # rubocop:disable-next Metrics/CyclomaticComplexity
219
+ def perform_human_render
220
+ # --json-stream works standalone, independent of --json: progress events go to stderr while
221
+ # human-readable output keeps using stdout as always. There is no synthetic final "result"
222
+ # event here - human mode
223
+ # builds no Result/payload to report, and the human-readable log itself is the completion/
224
+ # failure signal; see Notifications::JsonSubscriber's own #emit_result, never called here.
225
+ stream = Notifications::JsonSubscriber.new if json_stream?
226
+
227
+ validate_required_options!
228
+ configure
229
+
230
+ Bidi2pdf.logger.info "Rendering: #{requested_source_description} -> #{merged_options[:output]}"
231
+ Bidi2pdf.logger.info "Print options: #{print_options.inspect}" if print_options
232
+
233
+ validate_print_options(print_options) if print_options
234
+
235
+ launcher.launch
236
+ rescue Bidi2pdf::Error => e
237
+ raise Thor::Error, e.message
238
+ ensure
239
+ launcher.stop if defined?(@launcher) && @launcher
240
+ stream&.unsubscribe
241
+ cleanup_stdin_tempfile
242
+ end
243
+
244
+ # --- render: structured mode (--json and/or --output -). Every failure ends up as a Result
245
+ # rather than a raised exception, so this method itself never raises; it always exits
246
+ # explicitly instead.
247
+
248
+ def perform_structured_render
249
+ stream = Notifications::JsonSubscriber.new if json_stream?
250
+
251
+ reserve_stdout_for_machine_output do
252
+ configure
253
+
254
+ collector = ResultCollector.new(requested_url: requested_source_description, command: "render", output: render_output_label)
255
+
256
+ result = collector.around do
257
+ validate_required_options!
258
+ validate_print_options(print_options) if print_options
259
+ launcher.launch
260
+ end
261
+
262
+ stream&.emit_result(result)
263
+ write_manifest(result) if merged_options[:manifest]
264
+ emit_structured_render_output(result, collector)
265
+
266
+ exit_for_result(result)
267
+ end
268
+ ensure
269
+ launcher.stop if defined?(@launcher) && @launcher
270
+ stream&.unsubscribe
271
+ cleanup_stdin_tempfile
272
+ end
273
+
274
+ def emit_structured_render_output(result, collector)
275
+ if stdout_output?
276
+ $stdout.write(collector.pdf_bytes || "")
277
+ else
278
+ $stdout.puts(result.to_json)
279
+ end
280
+ end
281
+
282
+ # --- diagnose - reuses ResultCollector for console/network_failures/navigation exactly as
283
+ # render does, but its own JSON shape (page, fonts, print_media, paged_js, screenshot, pdf)
284
+ # rather than Result#to_h, since a page diagnostic and a render result are genuinely
285
+ # different documents.
286
+
287
+ def perform_human_diagnose
288
+ # See perform_human_render's own comment: --json-stream works standalone here too.
289
+ stream = Notifications::JsonSubscriber.new if json_stream?
290
+
291
+ validate_required_options!
292
+ configure
293
+
294
+ tab = launcher.diagnose
295
+ diagnostics = Diagnose.new(tab: tab).call
296
+ diagnostics[:screenshot] = capture_diagnose_screenshot(tab)
297
+ diagnostics[:pdf] = capture_diagnose_pdf(tab)
298
+
299
+ puts JSON.pretty_generate(diagnostics.compact)
300
+ rescue Bidi2pdf::Error => e
301
+ raise Thor::Error, e.message
302
+ ensure
303
+ launcher.stop if defined?(@launcher) && @launcher
304
+ stream&.unsubscribe
305
+ cleanup_stdin_tempfile
306
+ end
307
+
308
+ def perform_structured_diagnose
309
+ stream = Notifications::JsonSubscriber.new if json_stream?
310
+
311
+ reserve_stdout_for_machine_output do
312
+ configure
313
+
314
+ collector = ResultCollector.new(requested_url: requested_source_description, command: "diagnose")
315
+ diagnostics = {}
316
+
317
+ result = collector.around do
318
+ validate_required_options!
319
+ tab = launcher.diagnose
320
+ diagnostics.merge!(Diagnose.new(tab: tab).call)
321
+ diagnostics[:screenshot] = capture_diagnose_screenshot(tab)
322
+ diagnostics[:pdf] = capture_diagnose_pdf(tab)
323
+ end
324
+
325
+ stream&.emit_result(result)
326
+ $stdout.puts(diagnose_payload(result, diagnostics).to_json)
327
+
328
+ exit_for_result(result)
329
+ end
330
+ ensure
331
+ launcher.stop if defined?(@launcher) && @launcher
332
+ stream&.unsubscribe
333
+ cleanup_stdin_tempfile
334
+ end
335
+
336
+ def diagnose_payload(result, diagnostics)
337
+ {
338
+ schema_version: 1,
339
+ ok: result.ok?,
340
+ command: "diagnose",
341
+ page: diagnostics[:page],
342
+ console: result.console,
343
+ network_failures: result.network_failures,
344
+ fonts: diagnostics[:fonts],
345
+ print_media: diagnostics[:print_media],
346
+ paged_js: diagnostics[:paged_js],
347
+ screenshot: diagnostics[:screenshot],
348
+ pdf: diagnostics[:pdf],
349
+ warnings: result.warnings,
350
+ error: result.error
351
+ }
352
+ end
353
+
354
+ def capture_diagnose_screenshot(tab)
355
+ return nil unless merged_options[:screenshot]
356
+
357
+ tab.screenshot(merged_options[:screenshot])
358
+ merged_options[:screenshot]
359
+ end
360
+
361
+ def capture_diagnose_pdf(tab)
362
+ return nil unless merged_options[:pdf]
363
+
364
+ bytes = nil
365
+ tab.print(merged_options[:pdf]) { |pdf_base64| bytes = Base64.decode64(pdf_base64) }
366
+
367
+ { path: merged_options[:pdf], pages: PdfInspection.page_count(bytes), not_blank: bytes ? !PdfInspection.text(bytes).to_s.strip.empty? : nil }
368
+ end
369
+
370
+ # --- run - Loader/Validator run before any browser work, both in --validate and in a real
371
+ # run, so an invalid recipe never launches one.
372
+
373
+ # rubocop:disable-next Metrics/CyclomaticComplexity
374
+ def perform_human_run(recipe_path)
375
+ recipe = load_and_validate_recipe(recipe_path)
376
+ return puts "Recipe '#{recipe_path}' is valid." if merged_options[:validate]
377
+
378
+ # See perform_human_render's own comment: --json-stream works standalone here too. Human
379
+ # mode's own printed summary below is a subset of the full `run` schema shape (no
380
+ # schema_version/ok/command/output/...), so - consistent with the other two human paths -
381
+ # there is still no synthetic final "result" event; building the full shape here just for
382
+ # that would duplicate perform_structured_run's own run_payload.
383
+ stream = Notifications::JsonSubscriber.new if json_stream?
384
+
385
+ configure
386
+ state = {}
387
+ action_entries = []
388
+ assertion_entries = []
389
+ collector = ResultCollector.new(requested_url: recipe_requested_url(recipe), command: "run")
390
+
391
+ result = collector.around do
392
+ with_recipe_tab(recipe) do |tab|
393
+ runner = Recipe::Runner.new(recipe: recipe, tab: tab, collector: collector)
394
+ run_recipe_steps(runner, :run_actions) { |entries| action_entries = entries }
395
+ runner.pdf_bytes = print_for_recipe(tab, recipe) if recipe.needs_pdf?
396
+ run_recipe_steps(runner, :run_assertions) { |entries| assertion_entries = entries }
397
+ write_recipe_screenshot(tab, recipe)
398
+ state = runner.state
399
+ end
400
+ end
401
+
402
+ raise Thor::Error, result.error[:message] unless result.ok?
403
+
404
+ puts JSON.pretty_generate(actions: action_entries, assertions: assertion_entries, assigned: state)
405
+ rescue Bidi2pdf::Error => e
406
+ raise Thor::Error, e.message
407
+ ensure
408
+ @run_launcher&.stop
409
+ stream&.unsubscribe
410
+ cleanup_stdin_tempfile
411
+ end
412
+
413
+ # rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
414
+ def perform_structured_run(recipe_path)
415
+ recipe = load_and_validate_recipe(recipe_path)
416
+
417
+ if merged_options[:validate]
418
+ puts({ schema_version: 1, ok: true, command: "run", recipe: recipe_path }.to_json)
419
+ exit(Bidi2pdf::ExitCodes::SUCCESS)
420
+ end
421
+
422
+ stream = Notifications::JsonSubscriber.new if json_stream?
423
+
424
+ reserve_stdout_for_machine_output do
425
+ configure
426
+
427
+ collector = ResultCollector.new(requested_url: recipe_requested_url(recipe), command: "run", output: recipe.output["pdf"])
428
+ state = {}
429
+ action_entries = []
430
+ assertion_entries = []
431
+
432
+ result = collector.around do
433
+ with_recipe_tab(recipe) do |tab|
434
+ runner = Recipe::Runner.new(recipe: recipe, tab: tab, collector: collector)
435
+ run_recipe_steps(runner, :run_actions) { |entries| action_entries = entries }
436
+ runner.pdf_bytes = print_for_recipe(tab, recipe) if recipe.needs_pdf?
437
+ run_recipe_steps(runner, :run_assertions) { |entries| assertion_entries = entries }
438
+ write_recipe_screenshot(tab, recipe)
439
+ state = runner.state
440
+ end
441
+ end
442
+
443
+ write_recipe_manifest(recipe, result) if recipe.output["manifest"] && result.ok?
444
+
445
+ payload = run_payload(recipe_path, recipe, result, action_entries, assertion_entries, state)
446
+ stream&.emit_result(payload)
447
+ $stdout.puts(payload.to_json)
448
+
449
+ exit_for_result(result)
450
+ end
451
+ rescue Bidi2pdf::InvalidRecipeError, Bidi2pdf::PdfInspectionUnavailableError, Bidi2pdf::InvalidPrintOptionError => e
452
+ $stdout.puts Bidi2pdf::Result.failure(command: "run", error: ErrorCodes.describe(e)).to_json
453
+ exit(Bidi2pdf::ExitCodes.for(ErrorCodes.for(e)))
454
+ ensure
455
+ @run_launcher&.stop
456
+ stream&.unsubscribe
457
+ cleanup_stdin_tempfile
458
+ end
459
+
460
+ def load_and_validate_recipe(recipe_path)
461
+ recipe = Bidi2pdf::Recipe.load(recipe_path)
462
+ recipe.validate!
463
+ recipe
464
+ end
465
+
466
+ def recipe_requested_url(recipe)
467
+ recipe.source["url"] || recipe.source["file"] || "(stdin)"
468
+ end
469
+
470
+ def with_recipe_tab(recipe)
471
+ @run_launcher = launcher_for_recipe(recipe)
472
+ tab = @run_launcher.diagnose
473
+
474
+ yield(tab)
475
+ end
476
+
477
+ # A failing action/assertion raises Recipe::Runner::StepFailure (its own entries plus the real
478
+ # Bidi2pdf::Error as #cause). The block always receives the entries run so far - whether every
479
+ # step succeeded or the run stopped partway - before the cause is re-raised so
480
+ # ResultCollector's ordinary error handling takes over; without the block, a failure's partial
481
+ # entries would otherwise be lost along with the exception that discarded them.
482
+ def run_recipe_steps(runner, method_name)
483
+ entries = runner.public_send(method_name)
484
+ yield entries
485
+ entries
486
+ rescue Recipe::Runner::StepFailure => e
487
+ yield e.entries
488
+ raise e.cause
489
+ end
490
+
491
+ def launcher_for_recipe(recipe)
492
+ headless = merged_options[:headless]
493
+ headless = recipe.browser_options.fetch("headless", true) if headless.nil?
494
+
495
+ Bidi2pdf::Launcher.new(
496
+ url: recipe.source["url"],
497
+ inputfile: recipe_input_path(recipe),
498
+ output: nil,
499
+ cookies: recipe.cookies,
500
+ headers: recipe.headers,
501
+ auth: (recipe.auth || {}).transform_keys(&:to_sym),
502
+ port: 0,
503
+ remote_browser_url: merged_options[:remote_browser_url],
504
+ headless: headless,
505
+ wait_window_loaded: false,
506
+ wait_network_idle: false,
507
+ print_options: {},
508
+ network_log_format: "console"
509
+ )
510
+ end
511
+
512
+ def recipe_input_path(recipe)
513
+ source = recipe.source
514
+ return nil if source["url"]
515
+ return source["file"] if source["file"]
516
+ return write_stdin_tempfile if source["stdin"]
517
+
518
+ nil
519
+ end
520
+
521
+ def print_for_recipe(tab, recipe)
522
+ bytes = nil
523
+ tab.print(recipe.output["pdf"], print_options: symbolize_print_options(recipe.print_options)) { |pdf_base64| bytes = Base64.decode64(pdf_base64) }
524
+ bytes
525
+ end
526
+
527
+ def symbolize_print_options(opts)
528
+ opts.to_h { |key, value| [key.to_sym, value] }
529
+ end
530
+
531
+ def write_recipe_screenshot(tab, recipe)
532
+ return unless recipe.output["screenshot"]
533
+
534
+ tab.screenshot(recipe.output["screenshot"])
535
+ end
536
+
537
+ def write_recipe_manifest(recipe, result)
538
+ Manifest.new(
539
+ result: result,
540
+ input: { type: recipe_input_type(recipe), url: recipe.source["url"] || recipe.source["file"] },
541
+ headers: recipe.headers,
542
+ browser: { browser_name: "chrome", headless: merged_options[:headless] }
543
+ ).tap { |manifest| File.write(recipe.output["manifest"], "#{manifest.to_json}\n") }
544
+ end
545
+
546
+ def recipe_input_type(recipe)
547
+ return "url" if recipe.source["url"]
548
+ return "html_file" if recipe.source["file"]
549
+
550
+ "stdin"
551
+ end
552
+
553
+ def run_payload(recipe_path, recipe, result, action_entries, assertion_entries, state)
554
+ {
555
+ schema_version: 1,
556
+ ok: result.ok?,
557
+ command: "run",
558
+ recipe: recipe_path,
559
+ actions: action_entries,
560
+ assertions: assertion_entries,
561
+ assigned: state,
562
+ output: result.ok? ? recipe_output_summary(recipe, result) : nil,
563
+ duration_ms: result.duration_ms,
564
+ warnings: result.warnings,
565
+ error: result.error
566
+ }
567
+ end
568
+
569
+ def recipe_output_summary(recipe, result)
570
+ { pdf: recipe.output["pdf"], manifest: recipe.output["manifest"], screenshot: recipe.output["screenshot"], bytes: result.bytes, sha256: result.sha256,
571
+ pages: result.pages }.compact
572
+ end
573
+
574
+ # --json and --output - both need exclusive use of stdout - rejected before anything else
575
+ # runs, so neither stream is touched. The rejection
576
+ # itself is reported on stderr, since stdout's meaning is exactly what is in dispute.
577
+ def reject_conflicting_output_streams!
578
+ error = Bidi2pdf::InvalidConfigError.new("--json and --output - cannot be used together")
579
+ warn Bidi2pdf::Result.failure(command: "render", error: ErrorCodes.describe(error)).to_json
580
+ exit(Bidi2pdf::ExitCodes.for("INVALID_CONFIG"))
581
+ end
582
+
583
+ def json_mode? = merged_options[:json]
584
+
585
+ def json_stream? = merged_options[:json_stream]
586
+
587
+ def stdin_mode? = merged_options[:stdin]
588
+
589
+ def stdout_output? = merged_options[:output] == "-"
590
+
591
+ def render_output_label = stdout_output? ? "-" : merged_options[:output]
592
+
593
+ def render_output_path = stdout_output? ? nil : merged_options[:output]
594
+
595
+ def requested_source_description
596
+ merged_options[:url] || merged_options[:html_file] || "(stdin)"
597
+ end
598
+
599
+ def write_manifest(result)
600
+ manifest = Manifest.new(
601
+ result: result,
602
+ input: { type: input_type, url: requested_source_description },
603
+ headers: parse_key_values(merged_options[:header]),
604
+ navigation_duration_ms: nil,
605
+ browser: { browser_name: "chrome", headless: merged_options[:headless] }
606
+ )
607
+
608
+ File.write(merged_options[:manifest], "#{manifest.to_json}\n")
609
+ rescue SystemCallError => e
610
+ raise Bidi2pdf::OutputWriteError, "Could not write manifest '#{merged_options[:manifest]}': #{e.message}"
611
+ end
612
+
613
+ def input_type
614
+ return "url" if merged_options[:url]
615
+ return "html_file" if merged_options[:html_file]
616
+
617
+ "stdin"
618
+ end
619
+
620
+ # --- input sources: --url / --html-file / --stdin are mutually exclusive. #render_input_path
621
+ # is what actually reaches Launcher: for --stdin it is a Tempfile (cleaned up in the render
622
+ # methods' own ensure) holding the piped content, reusing the exact same --html-file code
623
+ # path rather than adding a second one.
624
+
139
625
  def load_config
140
626
  return unless options[:config] && File.exist?(options[:config])
141
627
 
142
628
  YAML.load_file(options[:config]).transform_keys(&:to_sym)
143
629
  end
144
630
 
631
+ def input_sources_provided
632
+ sources = []
633
+ sources << :url if merged_options[:url]
634
+ sources << :html_file if merged_options[:html_file]
635
+ sources << :stdin if stdin_mode?
636
+ sources
637
+ end
638
+
145
639
  def validate_required_options!
146
- if merged_options[:url].nil? && merged_options[:html_file].nil?
147
- raise Thor::Error, "Missing required option --url or --html_file needs to be specified"
640
+ provided = input_sources_provided
641
+
642
+ if provided.empty?
643
+ raise Bidi2pdf::MissingInputError, "Missing required option --url or --html-file needs to be specified"
644
+ elsif provided.size > 1
645
+ raise Bidi2pdf::MultipleInputSourcesError, "Only one of --url, --html-file, --stdin may be given (got: #{provided.join(", ")})"
148
646
  elsif merged_options[:html_file] && !File.readable?(merged_options[:html_file])
149
- raise Thor::Error, "HTML file '#{merged_options[:html_file]}' not found or not readable"
647
+ raise Bidi2pdf::InvalidConfigError, "HTML file '#{merged_options[:html_file]}' not found or not readable"
150
648
  end
151
649
  end
152
650
 
651
+ def render_input_path
652
+ return @render_input_path if defined?(@render_input_path)
653
+
654
+ @render_input_path = stdin_mode? ? write_stdin_tempfile : merged_options[:html_file]
655
+ end
656
+
657
+ def stdin_content
658
+ @stdin_content ||= $stdin.read.to_s
659
+ end
660
+
661
+ def write_stdin_tempfile
662
+ raise Bidi2pdf::EmptyInputError, "No HTML was provided on stdin" if stdin_content.strip.empty?
663
+
664
+ @stdin_tempfile = Tempfile.new(["bidi2pdf-stdin", ".html"])
665
+ @stdin_tempfile.write(stdin_content)
666
+ @stdin_tempfile.flush
667
+ @stdin_tempfile.path
668
+ end
669
+
670
+ def cleanup_stdin_tempfile
671
+ return unless @stdin_tempfile
672
+
673
+ @stdin_tempfile.close
674
+ @stdin_tempfile.unlink
675
+ @stdin_tempfile = nil
676
+ end
677
+
678
+ def version_info
679
+ {
680
+ schema_version: 1,
681
+ ok: true,
682
+ command: "version",
683
+ bidi2pdf: Bidi2pdf::VERSION,
684
+ ruby: RUBY_VERSION,
685
+ pdf_reader: pdf_reader_version
686
+ }
687
+ end
688
+
689
+ def pdf_reader_version
690
+ return nil unless Bidi2pdf::PdfInspection.available?
691
+
692
+ Gem.loaded_specs["pdf-reader"]&.version&.to_s
693
+ end
694
+
153
695
  def validate_print_options(opts)
154
696
  Bidi2pdf::Bidi::Commands::PrintParametersValidator.validate!(opts)
155
697
  rescue ArgumentError => e
156
- raise Thor::Error, "Invalid print option: #{e.message}"
698
+ raise Bidi2pdf::InvalidPrintOptionError, "Invalid print option: #{e.message}"
157
699
  end
158
700
 
159
701
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@@ -221,8 +763,8 @@ module Bidi2pdf
221
763
 
222
764
  Bidi2pdf::Launcher.new(
223
765
  url: merged_options[:url],
224
- inputfile: merged_options[:hmtl_file],
225
- output: merged_options[:output],
766
+ inputfile: render_input_path,
767
+ output: render_output_path,
226
768
  cookies: parse_key_values(merged_options[:cookie]),
227
769
  headers: parse_key_values(merged_options[:header]),
228
770
  auth: { username: username, password: password },