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.
- checksums.yaml +4 -4
- data/.rubocop.yml +20 -0
- data/CHANGELOG.md +69 -2
- data/README.md +258 -10
- data/docker/Dockerfile +4 -0
- data/docker/Dockerfile.slim +5 -1
- data/lib/bidi2pdf/bidi/browser_tab.rb +45 -4
- data/lib/bidi2pdf/bidi/buffered_web_socket_client.rb +178 -0
- data/lib/bidi2pdf/bidi/client.rb +12 -5
- data/lib/bidi2pdf/bidi/commands/base.rb +2 -0
- data/lib/bidi2pdf/bidi/network_event.rb +12 -3
- data/lib/bidi2pdf/bidi/network_events.rb +9 -1
- data/lib/bidi2pdf/bidi/session.rb +1 -1
- data/lib/bidi2pdf/chromedriver_manager.rb +10 -6
- 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/notifications/logging_subscriber.rb +2 -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_runner.rb +42 -0
- data/lib/bidi2pdf/session_warmer.rb +377 -0
- data/lib/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rb +4 -2
- data/lib/bidi2pdf/version.rb +1 -1
- data/lib/bidi2pdf.rb +114 -9
- data/sig/bidi2pdf/bidi/browser_tab.rbs +17 -0
- data/sig/bidi2pdf/bidi/buffered_web_socket_client.rbs +81 -0
- data/sig/bidi2pdf/bidi/client.rbs +10 -2
- data/sig/bidi2pdf/bidi/network_event.rbs +11 -1
- data/sig/bidi2pdf/bidi/session.rbs +1 -1
- data/sig/bidi2pdf/chromedriver_manager.rbs +4 -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_runner.rbs +17 -0
- data/sig/bidi2pdf/session_warmer.rbs +221 -0
- data/sig/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rbs +1 -1
- data/sig/bidi2pdf/version.rbs +1 -1
- data/sig/bidi2pdf.rbs +84 -0
- data/tasks/release_credentials_check.rake +97 -0
- 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
|