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.
- checksums.yaml +4 -4
- data/.rubocop.yml +20 -0
- data/CHANGELOG.md +46 -2
- data/README.md +233 -11
- data/docker/Dockerfile +11 -0
- data/docker/Dockerfile.chromedriver +7 -0
- data/docker/Dockerfile.slim +12 -1
- data/lib/bidi2pdf/bidi/buffered_web_socket_client.rb +9 -0
- data/lib/bidi2pdf/bidi/session.rb +5 -1
- data/lib/bidi2pdf/cli/json_output.rb +30 -0
- data/lib/bidi2pdf/cli.rb +559 -17
- data/lib/bidi2pdf/diagnose.rb +119 -0
- data/lib/bidi2pdf/error_codes.rb +59 -0
- data/lib/bidi2pdf/exit_codes.rb +44 -0
- data/lib/bidi2pdf/launcher.rb +23 -0
- data/lib/bidi2pdf/manifest.rb +65 -0
- data/lib/bidi2pdf/notifications/json_subscriber.rb +78 -0
- data/lib/bidi2pdf/pdf_inspection.rb +62 -0
- data/lib/bidi2pdf/recipe/loader.rb +44 -0
- data/lib/bidi2pdf/recipe/runner.rb +229 -0
- data/lib/bidi2pdf/recipe/schema_shape.rb +228 -0
- data/lib/bidi2pdf/recipe/validator.rb +138 -0
- data/lib/bidi2pdf/recipe.rb +83 -0
- data/lib/bidi2pdf/result.rb +67 -0
- data/lib/bidi2pdf/result_collector.rb +150 -0
- data/lib/bidi2pdf/schema.rb +382 -0
- data/lib/bidi2pdf/session_registry.rb +97 -0
- data/lib/bidi2pdf/session_runner.rb +42 -0
- data/lib/bidi2pdf/session_sweeper.rb +64 -0
- data/lib/bidi2pdf/session_warmer.rb +51 -1
- data/lib/bidi2pdf/version.rb +1 -1
- data/lib/bidi2pdf.rb +92 -8
- data/sig/bidi2pdf/bidi/buffered_web_socket_client.rbs +7 -0
- data/sig/bidi2pdf/bidi/session.rbs +6 -0
- data/sig/bidi2pdf/cli/json_output.rbs +19 -0
- data/sig/bidi2pdf/cli.rbs +112 -2
- data/sig/bidi2pdf/diagnose.rbs +30 -0
- data/sig/bidi2pdf/error_codes.rbs +21 -0
- data/sig/bidi2pdf/exit_codes.rbs +21 -0
- data/sig/bidi2pdf/launcher.rbs +9 -0
- data/sig/bidi2pdf/manifest.rbs +38 -0
- data/sig/bidi2pdf/notifications/json_subscriber.rbs +45 -0
- data/sig/bidi2pdf/pdf_inspection.rbs +33 -0
- data/sig/bidi2pdf/recipe/loader.rbs +20 -0
- data/sig/bidi2pdf/recipe/runner.rbs +92 -0
- data/sig/bidi2pdf/recipe/schema_shape.rbs +85 -0
- data/sig/bidi2pdf/recipe/validator.rbs +54 -0
- data/sig/bidi2pdf/recipe.rbs +72 -0
- data/sig/bidi2pdf/result.rbs +71 -0
- data/sig/bidi2pdf/result_collector.rbs +106 -0
- data/sig/bidi2pdf/schema.rbs +45 -0
- data/sig/bidi2pdf/session_registry.rbs +44 -0
- data/sig/bidi2pdf/session_runner.rbs +17 -0
- data/sig/bidi2pdf/session_sweeper.rbs +36 -0
- data/sig/bidi2pdf/session_warmer.rbs +33 -0
- data/sig/bidi2pdf/version.rbs +1 -1
- data/sig/bidi2pdf.rbs +66 -0
- 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
|
-
|
|
101
|
+
if json_mode? && stdout_output?
|
|
102
|
+
reject_conflicting_output_streams!
|
|
103
|
+
return
|
|
104
|
+
end
|
|
92
105
|
|
|
93
|
-
|
|
106
|
+
json_mode? || stdout_output? ? perform_structured_render : perform_human_render
|
|
107
|
+
end
|
|
94
108
|
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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 "
|
|
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
|
|
108
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
|
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
|
|
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:
|
|
225
|
-
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 },
|