bidi2pdf 0.1.14 → 0.1.16

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +20 -0
  3. data/CHANGELOG.md +69 -2
  4. data/README.md +258 -10
  5. data/docker/Dockerfile +4 -0
  6. data/docker/Dockerfile.slim +5 -1
  7. data/lib/bidi2pdf/bidi/browser_tab.rb +45 -4
  8. data/lib/bidi2pdf/bidi/buffered_web_socket_client.rb +178 -0
  9. data/lib/bidi2pdf/bidi/client.rb +12 -5
  10. data/lib/bidi2pdf/bidi/commands/base.rb +2 -0
  11. data/lib/bidi2pdf/bidi/network_event.rb +12 -3
  12. data/lib/bidi2pdf/bidi/network_events.rb +9 -1
  13. data/lib/bidi2pdf/bidi/session.rb +1 -1
  14. data/lib/bidi2pdf/chromedriver_manager.rb +10 -6
  15. data/lib/bidi2pdf/cli/json_output.rb +30 -0
  16. data/lib/bidi2pdf/cli.rb +559 -17
  17. data/lib/bidi2pdf/diagnose.rb +119 -0
  18. data/lib/bidi2pdf/error_codes.rb +59 -0
  19. data/lib/bidi2pdf/exit_codes.rb +44 -0
  20. data/lib/bidi2pdf/launcher.rb +23 -0
  21. data/lib/bidi2pdf/manifest.rb +65 -0
  22. data/lib/bidi2pdf/notifications/json_subscriber.rb +78 -0
  23. data/lib/bidi2pdf/notifications/logging_subscriber.rb +2 -0
  24. data/lib/bidi2pdf/pdf_inspection.rb +62 -0
  25. data/lib/bidi2pdf/recipe/loader.rb +44 -0
  26. data/lib/bidi2pdf/recipe/runner.rb +229 -0
  27. data/lib/bidi2pdf/recipe/schema_shape.rb +228 -0
  28. data/lib/bidi2pdf/recipe/validator.rb +138 -0
  29. data/lib/bidi2pdf/recipe.rb +83 -0
  30. data/lib/bidi2pdf/result.rb +67 -0
  31. data/lib/bidi2pdf/result_collector.rb +150 -0
  32. data/lib/bidi2pdf/schema.rb +382 -0
  33. data/lib/bidi2pdf/session_runner.rb +42 -0
  34. data/lib/bidi2pdf/session_warmer.rb +377 -0
  35. data/lib/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rb +4 -2
  36. data/lib/bidi2pdf/version.rb +1 -1
  37. data/lib/bidi2pdf.rb +114 -9
  38. data/sig/bidi2pdf/bidi/browser_tab.rbs +17 -0
  39. data/sig/bidi2pdf/bidi/buffered_web_socket_client.rbs +81 -0
  40. data/sig/bidi2pdf/bidi/client.rbs +10 -2
  41. data/sig/bidi2pdf/bidi/network_event.rbs +11 -1
  42. data/sig/bidi2pdf/bidi/session.rbs +1 -1
  43. data/sig/bidi2pdf/chromedriver_manager.rbs +4 -0
  44. data/sig/bidi2pdf/cli/json_output.rbs +19 -0
  45. data/sig/bidi2pdf/cli.rbs +112 -2
  46. data/sig/bidi2pdf/diagnose.rbs +30 -0
  47. data/sig/bidi2pdf/error_codes.rbs +21 -0
  48. data/sig/bidi2pdf/exit_codes.rbs +21 -0
  49. data/sig/bidi2pdf/launcher.rbs +9 -0
  50. data/sig/bidi2pdf/manifest.rbs +38 -0
  51. data/sig/bidi2pdf/notifications/json_subscriber.rbs +45 -0
  52. data/sig/bidi2pdf/pdf_inspection.rbs +33 -0
  53. data/sig/bidi2pdf/recipe/loader.rbs +20 -0
  54. data/sig/bidi2pdf/recipe/runner.rbs +92 -0
  55. data/sig/bidi2pdf/recipe/schema_shape.rbs +85 -0
  56. data/sig/bidi2pdf/recipe/validator.rbs +54 -0
  57. data/sig/bidi2pdf/recipe.rbs +72 -0
  58. data/sig/bidi2pdf/result.rbs +71 -0
  59. data/sig/bidi2pdf/result_collector.rbs +106 -0
  60. data/sig/bidi2pdf/schema.rbs +45 -0
  61. data/sig/bidi2pdf/session_runner.rbs +17 -0
  62. data/sig/bidi2pdf/session_warmer.rbs +221 -0
  63. data/sig/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rbs +1 -1
  64. data/sig/bidi2pdf/version.rbs +1 -1
  65. data/sig/bidi2pdf.rbs +84 -0
  66. data/tasks/release_credentials_check.rake +97 -0
  67. metadata +39 -4
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bidi2pdf
4
+ class Recipe
5
+ # Runs a validated recipe's actions and assertions against a navigated tab. A thin dispatch
6
+ # over BrowserTab's own methods, not a parallel action/assertion class hierarchy - a step
7
+ # that needs something BrowserTab cannot do belongs there, not here.
8
+ class Runner
9
+ # Raised on the first failing action/assertion; #entries carries every step run so far
10
+ # (successes and the failure itself), for the caller to report in the recipe result.
11
+ class StepFailure < StandardError
12
+ attr_reader :entries, :cause
13
+
14
+ def initialize(entries, cause)
15
+ @entries = entries
16
+ @cause = cause
17
+ super(cause.message)
18
+ end
19
+ end
20
+
21
+ attr_reader :state
22
+ attr_accessor :pdf_bytes
23
+
24
+ # @param recipe [Bidi2pdf::Recipe]
25
+ # @param tab [Bidi2pdf::Bidi::BrowserTab] a navigated tab (e.g. from Launcher#diagnose)
26
+ # @param collector [Bidi2pdf::ResultCollector] source of #console/#network_failures -
27
+ # exposed live, so assertions can read them mid-run (see ResultCollector's own docs)
28
+ def initialize(recipe:, tab:, collector:)
29
+ @recipe = recipe
30
+ @tab = tab
31
+ @collector = collector
32
+ @state = {}
33
+ @pdf_bytes = nil
34
+ end
35
+
36
+ # @return [Array<Hash>] one entry per action actually run
37
+ # @raise [StepFailure] on the first failing action
38
+ def run_actions
39
+ run_steps(@recipe.actions) { |name, step| dispatch_action(name, @recipe.step_options(step)) }
40
+ end
41
+
42
+ # @return [Array<Hash>] one entry per assertion actually run
43
+ # @raise [StepFailure] on the first failing assertion
44
+ def run_assertions
45
+ run_steps(@recipe.assertions) do |name, step|
46
+ ok, details = dispatch_assertion(name, step)
47
+ next { ok: ok, details: details } if ok
48
+
49
+ raise Bidi2pdf::PageNotAsExpectedError.new("Assertion '#{name}' failed", details: (details || {}).merge(assertion: name))
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ # rubocop:disable-next Metrics/AbcSize
56
+ def run_steps(steps)
57
+ entries = []
58
+
59
+ steps.each_with_index do |step, index|
60
+ name = @recipe.step_name(step)
61
+ start = now_ms
62
+
63
+ begin
64
+ outcome = yield(name, step)
65
+ entry = { index: index, type: name, ok: true, duration_ms: elapsed_ms(start) }
66
+ entry[:details] = outcome[:details] if outcome.is_a?(Hash) && outcome[:details]
67
+ entries << entry
68
+ rescue Bidi2pdf::Error => e
69
+ entries << { index: index, type: name, ok: false, duration_ms: elapsed_ms(start) }
70
+ raise StepFailure.new(entries, e)
71
+ end
72
+ end
73
+
74
+ entries
75
+ end
76
+
77
+ def now_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
78
+
79
+ def elapsed_ms(start) = (now_ms - start).round
80
+
81
+ # --- actions ---
82
+
83
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity
84
+ def dispatch_action(name, opts)
85
+ case name
86
+ when "wait_for" then action_wait_for(opts)
87
+ when "click" then action_click(opts)
88
+ when "evaluate" then action_evaluate(opts)
89
+ when "inject_script" then @tab.inject_script(url: opts["url"], content: opts["content"], id: opts["id"])
90
+ when "inject_style" then @tab.inject_style(url: opts["url"], content: opts["content"], id: opts["id"])
91
+ when "set_viewport" then @tab.set_viewport(width: opts["width"], height: opts["height"], device_pixel_ratio: opts["device_pixel_ratio"])
92
+ when "wait_network_idle" then @tab.wait_until_network_idle(timeout: opts.fetch("timeout", 10))
93
+ end
94
+ end
95
+
96
+ def action_wait_for(opts)
97
+ timeout = opts.fetch("timeout", 10)
98
+ condition = wait_for_condition(opts)
99
+
100
+ script = <<~JS
101
+ new Promise((resolve, reject) => {
102
+ const deadline = Date.now() + #{(timeout.to_f * 1000).to_i};
103
+ const check = () => {
104
+ let ok;
105
+ try { ok = !!(#{condition}); } catch (e) { ok = false; }
106
+ if (ok) { resolve("done"); return; }
107
+ if (Date.now() >= deadline) { reject(new Error("wait_for timed out")); return; }
108
+ setTimeout(check, 100);
109
+ };
110
+ check();
111
+ });
112
+ JS
113
+
114
+ response = @tab.execute_script(script)
115
+ return if response.is_a?(Hash) && response["type"] == "success"
116
+
117
+ raise Bidi2pdf::SelectorNotFoundError.new("wait_for timed out after #{timeout}s", details: opts)
118
+ end
119
+
120
+ def wait_for_condition(opts)
121
+ return "document.querySelector('.pagedjs_page')" if opts["paged_js"]
122
+ return "document.querySelector(#{opts.fetch("selector").to_json})" if opts["selector"]
123
+ return opts.fetch("script") if opts["script"]
124
+
125
+ raise Bidi2pdf::InvalidRecipeError, "wait_for needs one of: selector, paged_js, script"
126
+ end
127
+
128
+ def action_click(opts)
129
+ selector = opts.fetch("selector") { raise Bidi2pdf::InvalidRecipeError, "click needs a selector" }
130
+
131
+ script = <<~JS
132
+ (function () {
133
+ const el = document.querySelector(#{selector.to_json});
134
+ if (!el) { return false; }
135
+ el.scrollIntoView({ block: "center" });
136
+ el.click();
137
+ return true;
138
+ })()
139
+ JS
140
+
141
+ response = @tab.execute_script(script)
142
+ return if response.is_a?(Hash) && response["type"] == "success" && response.dig("result", "value") == true
143
+
144
+ raise Bidi2pdf::SelectorNotFoundError.new("click: selector '#{selector}' not found", details: opts)
145
+ end
146
+
147
+ def action_evaluate(opts)
148
+ script = opts.fetch("script") { raise Bidi2pdf::InvalidRecipeError, "evaluate needs a script" }
149
+
150
+ response = @tab.execute_script(script)
151
+ raise Bidi2pdf::ScriptInjectionError.new("evaluate failed", details: opts) unless response.is_a?(Hash) && response["type"] == "success"
152
+
153
+ @state[opts["assign"]] = response.dig("result", "value") if opts["assign"]
154
+ end
155
+
156
+ # --- assertions: each returns [ok, details_or_nil] ---
157
+
158
+ # rubocop:disable-next Metrics/CyclomaticComplexity
159
+ def dispatch_assertion(name, step)
160
+ case name
161
+ when "selector_exists" then assertion_selector_exists(@recipe.step_options(step))
162
+ when "text_present" then assertion_text_present(@recipe.step_options(step))
163
+ when "no_console_errors" then assertion_no_console_errors
164
+ when "no_network_failures" then assertion_no_network_failures
165
+ when "fonts_loaded" then assertion_fonts_loaded
166
+ when "page_count" then assertion_page_count(@recipe.step_value(step))
167
+ when "pdf_text_present" then assertion_pdf_text_present(@recipe.step_options(step))
168
+ when "pdf_not_blank" then assertion_pdf_not_blank
169
+ end
170
+ end
171
+
172
+ def assertion_selector_exists(opts)
173
+ selector = opts.fetch("selector")
174
+ response = @tab.execute_script("!!document.querySelector(#{selector.to_json})")
175
+ [response.is_a?(Hash) && response.dig("result", "value") == true, { selector: selector }]
176
+ end
177
+
178
+ def assertion_text_present(opts)
179
+ text = opts.fetch("text")
180
+ response = @tab.execute_script("document.body.innerText")
181
+ body_text = response.is_a?(Hash) ? response.dig("result", "value").to_s : ""
182
+ [body_text.include?(text), { text: text }]
183
+ end
184
+
185
+ def assertion_no_console_errors
186
+ errors = @collector.console.select { |entry| entry[:level].to_s == "error" }
187
+ [errors.empty?, errors.empty? ? nil : { console_errors: errors }]
188
+ end
189
+
190
+ # Filtering by resource type (a `types:` option) is not implemented: Bidi2pdf::Bidi::
191
+ # NetworkEvent carries an HTTP method, not a resource type, so there is nothing to filter
192
+ # by yet. Every captured failure counts.
193
+ def assertion_no_network_failures
194
+ failures = @collector.network_failures
195
+ [failures.empty?, failures.empty? ? nil : { network_failures: failures }]
196
+ end
197
+
198
+ def assertion_fonts_loaded
199
+ response = @tab.execute_script(<<~JS)
200
+ document.fonts.ready.then(function () {
201
+ return document.fonts.ready ? !Array.from(document.fonts).some(function (f) { return f.status === "error"; }) : true;
202
+ })
203
+ JS
204
+ ok = response.is_a?(Hash) && response["type"] == "success" && response.dig("result", "value") != false
205
+ [ok, nil]
206
+ end
207
+
208
+ def assertion_page_count(expected)
209
+ actual = Bidi2pdf::PdfInspection.page_count(pdf_bytes)
210
+
211
+ ok = expected.is_a?(Hash) ? (expected["min"].nil? || actual.to_i >= expected["min"]) && (expected["max"].nil? || actual.to_i <= expected["max"]) : actual == expected
212
+
213
+ [ok, { expected: expected, actual: actual }]
214
+ end
215
+
216
+ def assertion_pdf_text_present(opts)
217
+ text = opts.fetch("text")
218
+ extracted = Bidi2pdf::PdfInspection.text(pdf_bytes).to_s
219
+ [extracted.include?(text), { text: text }]
220
+ end
221
+
222
+ def assertion_pdf_not_blank
223
+ page_count = Bidi2pdf::PdfInspection.page_count(pdf_bytes).to_i
224
+ text = Bidi2pdf::PdfInspection.text(pdf_bytes).to_s
225
+ [page_count.positive? && !text.strip.empty?, { page_count: page_count, text_present: !text.strip.empty? }]
226
+ end
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bidi2pdf
4
+ class Recipe
5
+ # Structural validation against Schema::RECIPE itself, not a hand-duplicated parallel list of
6
+ # rules. Validator's other #check_* methods each enforce one *semantic* rule (an action name
7
+ # is known, a source key is truthy, a presence assertion is `true`) with a friendly, specific
8
+ # message - none of them enforce the schema's own `additionalProperties: false` at every
9
+ # level, so an extra, unrecognized key sitting alongside an otherwise-valid one used to pass
10
+ # silently even though `bidi2pdf schema recipe` would reject it (`{url: "...", stdin: false}`,
11
+ # `{wait_for: {...}, extra: 1}` - two action names in one step, `wait_for: {selector: "#x",
12
+ # bogus: "y"}`). #check_shape (see Validator) closes that whole class of gap generically by
13
+ # walking the schema the CLI already publishes, instead of re-describing "what's allowed" a
14
+ # second time in Ruby - the schema is the one place that description can't drift from itself.
15
+ #
16
+ # Deliberately not a general JSON Schema engine and not a gem dependency: only the keywords
17
+ # Schema::RECIPE actually uses (type, const, enum, required, additionalProperties, properties,
18
+ # oneOf, anyOf, items, minimum, maximum). Extend the keyword list only if a future schema
19
+ # branch genuinely needs one this doesn't cover yet.
20
+ module SchemaShape # rubocop:disable Metrics/ModuleLength
21
+ TYPE_CHECKS = {
22
+ "object" => ->(v) { v.is_a?(Hash) },
23
+ "array" => ->(v) { v.is_a?(Array) },
24
+ "string" => ->(v) { v.is_a?(String) },
25
+ "integer" => ->(v) { v.is_a?(Integer) },
26
+ "number" => ->(v) { v.is_a?(Numeric) },
27
+ "boolean" => ->(v) { [true, false].include?(v) },
28
+ "null" => lambda(&:nil?)
29
+ }.freeze
30
+
31
+ CHECKS = %i[const_ok? enum_ok? type_ok? one_of_ok? any_of_ok? required_ok? additional_properties_ok? properties_ok? items_ok? bounds_ok?].freeze
32
+
33
+ module_function
34
+
35
+ # @return [Boolean] whether value satisfies schema
36
+ def matches?(schema, value)
37
+ CHECKS.all? { |check| public_send(check, schema, value) }
38
+ end
39
+
40
+ VIOLATION_CHECKS = %i[
41
+ oneof_violation anyof_violation required_violation additional_properties_violation const_violation enum_violation type_violation
42
+ bounds_violation nested_violation
43
+ ].freeze
44
+
45
+ # @return [Hash, nil] {path:, reason:} for the first violation a depth-first walk finds, or
46
+ # nil if value already satisfies schema. Not necessarily *the* most relevant violation
47
+ # when several exist at once - good enough to point an agent at the right neighborhood,
48
+ # not a substitute for reading `bidi2pdf schema recipe` for the exact shape.
49
+ def first_violation(schema, value, path = [])
50
+ return nil if matches?(schema, value)
51
+
52
+ VIOLATION_CHECKS.each do |check|
53
+ found = public_send(check, schema, value, path)
54
+ return found if found
55
+ end
56
+
57
+ { path: display_path(path), reason: "does not match the schema" }
58
+ end
59
+
60
+ def const_ok?(schema, value)
61
+ !schema.key?("const") || schema["const"] == value
62
+ end
63
+
64
+ def enum_ok?(schema, value)
65
+ !schema.key?("enum") || schema["enum"].include?(value)
66
+ end
67
+
68
+ def type_ok?(schema, value)
69
+ types = Array(schema["type"])
70
+ return true if types.empty?
71
+
72
+ types.any? { |t| TYPE_CHECKS.fetch(t).call(value) }
73
+ end
74
+
75
+ def one_of_ok?(schema, value)
76
+ return true unless schema["oneOf"]
77
+
78
+ schema["oneOf"].one? { |branch| matches?(branch, value) }
79
+ end
80
+
81
+ def any_of_ok?(schema, value)
82
+ return true unless schema["anyOf"]
83
+
84
+ schema["anyOf"].any? { |branch| matches?(branch, value) }
85
+ end
86
+
87
+ def required_ok?(schema, value)
88
+ return true unless schema["required"] && value.is_a?(Hash)
89
+
90
+ schema["required"].all? { |key| value.key?(key) }
91
+ end
92
+
93
+ def additional_properties_ok?(schema, value)
94
+ return true unless schema["additionalProperties"] == false && value.is_a?(Hash)
95
+
96
+ (value.keys - (schema["properties"] || {}).keys).empty?
97
+ end
98
+
99
+ def properties_ok?(schema, value)
100
+ return true unless schema["properties"] && value.is_a?(Hash)
101
+
102
+ schema["properties"].all? { |key, sub_schema| !value.key?(key) || matches?(sub_schema, value[key]) }
103
+ end
104
+
105
+ def items_ok?(schema, value)
106
+ return true unless schema["items"] && value.is_a?(Array)
107
+
108
+ value.all? { |item| matches?(schema["items"], item) }
109
+ end
110
+
111
+ def bounds_ok?(schema, value)
112
+ return true unless value.is_a?(Numeric)
113
+
114
+ (!schema["minimum"] || value >= schema["minimum"]) && (!schema["maximum"] || value <= schema["maximum"])
115
+ end
116
+
117
+ def display_path(path)
118
+ path.each_with_object(+"") do |seg, str|
119
+ str << (if seg.start_with?("[")
120
+ seg
121
+ else
122
+ (str.empty? ? seg : ".#{seg}")
123
+ end)
124
+ end
125
+ end
126
+
127
+ # When zero branches match but exactly one is "plausible" (its own required key(s) are
128
+ # present, ignoring additionalProperties), recurse into that one branch for a specific
129
+ # reason/path instead of a bare "matched 0 of N" - this is what turns "actions[0] doesn't
130
+ # match any known action" into "actions[0].wait_for: unknown key(s): bogus".
131
+ def oneof_violation(schema, value, path)
132
+ return nil unless schema["oneOf"]
133
+
134
+ matching = schema["oneOf"].select { |branch| matches?(branch, value) }
135
+ return nil if matching.size == 1
136
+
137
+ (matching.empty? && single_plausible_violation(schema, value, path)) ||
138
+ { path: display_path(path), reason: "must match exactly one of #{schema["oneOf"].size} known shapes (matched #{matching.size})" }
139
+ end
140
+
141
+ def single_plausible_violation(schema, value, path)
142
+ plausible = schema["oneOf"].select { |branch| required_ok?(branch, value) }
143
+ return nil unless plausible.size == 1
144
+
145
+ first_violation(plausible.first, value, path)
146
+ end
147
+
148
+ def anyof_violation(schema, value, path)
149
+ return nil unless schema["anyOf"]
150
+ return nil if schema["anyOf"].any? { |branch| matches?(branch, value) }
151
+
152
+ { path: display_path(path), reason: "must match at least one of #{schema["anyOf"].size} known shapes" }
153
+ end
154
+
155
+ def required_violation(schema, value, path)
156
+ return nil unless schema["required"] && value.is_a?(Hash)
157
+
158
+ missing = schema["required"] - value.keys
159
+ return nil if missing.empty?
160
+
161
+ { path: display_path(path), reason: "missing required key(s): #{missing.join(", ")}" }
162
+ end
163
+
164
+ def additional_properties_violation(schema, value, path)
165
+ return nil unless schema["additionalProperties"] == false && value.is_a?(Hash)
166
+
167
+ extra = value.keys - (schema["properties"] || {}).keys
168
+ return nil if extra.empty?
169
+
170
+ { path: display_path(path), reason: "unknown key(s): #{extra.join(", ")}" }
171
+ end
172
+
173
+ def const_violation(schema, value, path)
174
+ return nil unless schema.key?("const") && schema["const"] != value
175
+
176
+ { path: display_path(path), reason: "must equal #{schema["const"].inspect}" }
177
+ end
178
+
179
+ def enum_violation(schema, value, path)
180
+ return nil unless schema["enum"] && !schema["enum"].include?(value)
181
+
182
+ { path: display_path(path), reason: "must be one of #{schema["enum"].inspect}" }
183
+ end
184
+
185
+ def type_violation(schema, value, path)
186
+ return nil if type_ok?(schema, value)
187
+
188
+ { path: display_path(path), reason: "expected type #{Array(schema["type"]).join(" or ")}" }
189
+ end
190
+
191
+ def bounds_violation(schema, value, path)
192
+ return nil unless value.is_a?(Numeric)
193
+ return { path: display_path(path), reason: "must be >= #{schema["minimum"]}" } if schema["minimum"] && value < schema["minimum"]
194
+ return { path: display_path(path), reason: "must be <= #{schema["maximum"]}" } if schema["maximum"] && value > schema["maximum"]
195
+
196
+ nil
197
+ end
198
+
199
+ def nested_violation(schema, value, path)
200
+ properties_violation(schema, value, path) || items_violation(schema, value, path)
201
+ end
202
+
203
+ def properties_violation(schema, value, path)
204
+ return nil unless schema["properties"] && value.is_a?(Hash)
205
+
206
+ schema["properties"].each do |key, sub_schema|
207
+ next unless value.key?(key)
208
+
209
+ found = first_violation(sub_schema, value[key], path + [key])
210
+ return found if found
211
+ end
212
+
213
+ nil
214
+ end
215
+
216
+ def items_violation(schema, value, path)
217
+ return nil unless schema["items"] && value.is_a?(Array)
218
+
219
+ value.each_with_index do |item, index|
220
+ found = first_violation(schema["items"], item, path + ["[#{index}]"])
221
+ return found if found
222
+ end
223
+
224
+ nil
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bidi2pdf
4
+ class Recipe
5
+ # Validates a loaded recipe against the schema `bidi2pdf schema recipe` publishes, before any
6
+ # browser is launched - `bidi2pdf run --validate` runs exactly this and nothing else. Every
7
+ # failure is Bidi2pdf::InvalidRecipeError (or Bidi2pdf::PdfInspectionUnavailableError for a
8
+ # PDF assertion without pdf-reader installed), details carrying a `path` an agent can act on
9
+ # directly.
10
+ class Validator
11
+ def initialize(recipe)
12
+ @recipe = recipe
13
+ end
14
+
15
+ def validate!
16
+ check_version
17
+ check_top_level_keys
18
+ check_source
19
+ check_steps("actions", @recipe.actions, Recipe::KNOWN_ACTIONS)
20
+ check_steps("assert", @recipe.assertions, Recipe::KNOWN_ASSERTIONS)
21
+ check_wait_for_conditions
22
+ check_presence_assertions
23
+ check_pdf_assertions_need_pdf_inspection
24
+ check_output
25
+ check_print_options
26
+ check_shape
27
+ end
28
+
29
+ private
30
+
31
+ def check_version
32
+ return if @recipe.data["version"] == 1
33
+
34
+ fail!("version must be 1", path: "version")
35
+ end
36
+
37
+ def check_top_level_keys
38
+ unknown = @recipe.data.keys.map(&:to_s) - Recipe::KNOWN_TOP_LEVEL_KEYS
39
+ return if unknown.empty?
40
+
41
+ fail!("Unknown top-level key '#{unknown.first}'. Known keys: #{Recipe::KNOWN_TOP_LEVEL_KEYS.join(", ")}", path: unknown.first)
42
+ end
43
+
44
+ def check_source
45
+ given = %w[url file stdin].select { |key| @recipe.source[key] }
46
+
47
+ fail!("source must specify exactly one of url, file, stdin", path: "source") unless given.size == 1
48
+ end
49
+
50
+ def check_steps(section, steps, known)
51
+ steps.each_with_index do |step, index|
52
+ name = @recipe.step_name(step)
53
+ next if known.include?(name)
54
+
55
+ fail!("Unknown #{section == "actions" ? "action" : "assertion"} '#{name}'. Known: #{known.join(", ")}", path: "#{section}[#{index}].#{name}")
56
+ end
57
+ end
58
+
59
+ # Mirrors Schema::RECIPE_ACTIONS' own wait_for oneOf: exactly one of selector/paged_js/script.
60
+ # --validate runs this check itself rather than relying on Runner#wait_for_condition, whose
61
+ # own "needs one of" error only fires mid-run (after a browser is already launched) and does
62
+ # not catch "more than one given" at all - it just silently prefers paged_js, then selector.
63
+ def check_wait_for_conditions
64
+ @recipe.actions.each_with_index do |step, index|
65
+ next unless @recipe.step_name(step) == "wait_for"
66
+
67
+ given = %w[selector paged_js script] & @recipe.step_options(step).keys
68
+
69
+ next if given.size == 1
70
+
71
+ fail!("wait_for needs exactly one of selector, paged_js, script (got: #{given.empty? ? "none" : given.join(", ")})",
72
+ path: "actions[#{index}].wait_for")
73
+ end
74
+ end
75
+
76
+ # Mirrors Schema::RECIPE_ASSERT's own const: true for the 4 presence-only assertions -
77
+ # Runner never reads the value beside them, so anything but `true` is misleading rather than
78
+ # merely unusual (see Recipe::PRESENCE_ONLY_ASSERTIONS' own comment).
79
+ def check_presence_assertions
80
+ @recipe.assertions.each_with_index do |step, index|
81
+ name = @recipe.step_name(step)
82
+ next unless Recipe::PRESENCE_ONLY_ASSERTIONS.include?(name)
83
+ next if @recipe.step_value(step) == true
84
+
85
+ fail!("#{name} must be true - a false or missing value is misleading; omit the assertion instead", path: "assert[#{index}].#{name}")
86
+ end
87
+ end
88
+
89
+ def check_pdf_assertions_need_pdf_inspection
90
+ return if Bidi2pdf::PdfInspection.available?
91
+
92
+ @recipe.assertions.each_with_index do |assertion, index|
93
+ name = @recipe.step_name(assertion)
94
+ next unless Recipe::PDF_ASSERTIONS.include?(name)
95
+
96
+ raise Bidi2pdf::PdfInspectionUnavailableError.new(
97
+ "assert[#{index}].#{name} requires the pdf-reader gem",
98
+ details: { path: "assert[#{index}].#{name}", reason: "requires the pdf-reader gem" }
99
+ )
100
+ end
101
+ end
102
+
103
+ def check_output
104
+ return if Recipe::KNOWN_OUTPUTS.intersect?(@recipe.output.keys.map(&:to_s))
105
+
106
+ fail!("output must specify at least one of #{Recipe::KNOWN_OUTPUTS.join(", ")}", path: "output")
107
+ end
108
+
109
+ def check_print_options
110
+ return if @recipe.print_options.empty?
111
+
112
+ Bidi2pdf::Bidi::Commands::PrintParametersValidator.validate!(symbolize(@recipe.print_options))
113
+ rescue ArgumentError => e
114
+ raise Bidi2pdf::InvalidPrintOptionError.new("Invalid print option: #{e.message}", details: { path: "print" })
115
+ end
116
+
117
+ def symbolize(hash)
118
+ hash.to_h { |key, value| [key.to_sym, value] }
119
+ end
120
+
121
+ # Runs last, deliberately: everything above gives a friendlier, more specific message for
122
+ # the case it already knows about (an unknown action name, a non-true presence assertion, a
123
+ # real PrintParametersValidator error, ...), so this only ever fires for a shape violation
124
+ # none of them catches - an extra/unrecognized key sitting alongside an otherwise-valid one
125
+ # (SchemaShape's own doc comment has the concrete examples this closes).
126
+ def check_shape
127
+ violation = SchemaShape.first_violation(Bidi2pdf::Schema::RECIPE, @recipe.data)
128
+ return unless violation
129
+
130
+ fail!(violation[:reason], path: violation[:path])
131
+ end
132
+
133
+ def fail!(reason, path:)
134
+ raise Bidi2pdf::InvalidRecipeError.new(reason, details: { path: path, reason: reason })
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "recipe/schema_shape"
4
+ require_relative "recipe/loader"
5
+ require_relative "recipe/validator"
6
+ require_relative "recipe/runner"
7
+
8
+ module Bidi2pdf
9
+ # A recipe is a rendering contract: the waits and preparation a page needs before it is printed,
10
+ # and the properties the resulting PDF must have - not a general browser-automation script.
11
+ # `bidi2pdf run recipe.yml` is Loader -> (an invalid recipe stops here, no browser ever
12
+ # launches) -> Validator -> Runner.
13
+ class Recipe
14
+ KNOWN_TOP_LEVEL_KEYS = %w[version source browser headers cookies auth actions assert print output].freeze
15
+ KNOWN_ACTIONS = %w[wait_for click evaluate inject_script inject_style set_viewport wait_network_idle].freeze
16
+ PAGE_ASSERTIONS = %w[selector_exists text_present no_console_errors no_network_failures fonts_loaded].freeze
17
+ PDF_ASSERTIONS = %w[page_count pdf_text_present pdf_not_blank].freeze
18
+ KNOWN_ASSERTIONS = (PAGE_ASSERTIONS + PDF_ASSERTIONS).freeze
19
+ KNOWN_OUTPUTS = %w[pdf manifest screenshot].freeze
20
+ # These four assertions are pure presence checks - Runner never reads the value beside them
21
+ # (see #assertion_no_console_errors and friends), so a bare key is really what's being
22
+ # asserted. `false`/anything but `true` is therefore misleading, not merely unusual: schema
23
+ # recipe encodes this as `const: true` and Validator#check_presence_assertions enforces it too.
24
+ PRESENCE_ONLY_ASSERTIONS = %w[no_console_errors no_network_failures fonts_loaded pdf_not_blank].freeze
25
+
26
+ attr_reader :data, :path
27
+
28
+ def self.load(path)
29
+ new(Loader.load(path), path: path)
30
+ end
31
+
32
+ def initialize(data, path: nil)
33
+ @data = data
34
+ @path = path
35
+ end
36
+
37
+ def validate!
38
+ Validator.new(self).validate!
39
+ end
40
+
41
+ def actions = Array(@data["actions"])
42
+
43
+ def assertions = Array(@data["assert"])
44
+
45
+ def source = @data["source"] || {}
46
+
47
+ def browser_options = @data["browser"] || {}
48
+
49
+ def headers = @data["headers"] || {}
50
+
51
+ def cookies = @data["cookies"] || {}
52
+
53
+ def auth = @data["auth"]
54
+
55
+ def print_options = @data["print"] || {}
56
+
57
+ def output = @data["output"] || {}
58
+
59
+ def needs_pdf?
60
+ !!output["pdf"] || assertions.any? { |assertion| PDF_ASSERTIONS.include?(step_name(assertion)) }
61
+ end
62
+
63
+ def self.step_name(step)
64
+ step.is_a?(Hash) ? step.keys.first.to_s : step.to_s
65
+ end
66
+
67
+ def step_name(step) = self.class.step_name(step)
68
+
69
+ # The value beside a step's name - a Hash of options (`wait_for: {selector: ..., timeout:
70
+ # ...}`), a bare scalar (`page_count: 2`, `no_console_errors: true`), or nil.
71
+ def step_value(step)
72
+ step.is_a?(Hash) ? step.values.first : nil
73
+ end
74
+
75
+ # step_value coerced to a Hash of options - most actions/assertions take this shape; a bare
76
+ # scalar step (page_count: 2, no_console_errors: true) has no options of its own to offer here,
77
+ # so callers that need the scalar itself use #step_value directly.
78
+ def step_options(step)
79
+ value = step_value(step)
80
+ value.is_a?(Hash) ? value : {}
81
+ end
82
+ end
83
+ end