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
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Bidi2pdf
|
|
2
|
+
# Maps an exception to the stable, machine-readable code used by --json/--json-stream/manifests
|
|
3
|
+
# and recipe results. Derived from the existing Bidi2pdf::Error hierarchy rather than duplicated
|
|
4
|
+
# beside it: a new failure mode gets a new subclass in lib/bidi2pdf.rb and a row here, never a
|
|
5
|
+
# bare string minted ad hoc.
|
|
6
|
+
#
|
|
7
|
+
# MAPPING is ordered most-specific-subclass first - #for walks it in order and returns the first
|
|
8
|
+
# match, so a subclass must appear before any of its ancestors.
|
|
9
|
+
module ErrorCodes
|
|
10
|
+
MAPPING: ::Hash[untyped, "MISSING_INPUT" | "MULTIPLE_INPUT_SOURCES" | "EMPTY_INPUT" | "INVALID_CONFIG" | "INVALID_PRINT_OPTION" | "INVALID_RECIPE" | "PDF_INSPECTION_UNAVAILABLE" | "BROWSER_LAUNCH_FAILED" | "COMMAND_TIMEOUT" | "BROWSER_DISCONNECTED" | "NAVIGATION_TIMEOUT" | "NAVIGATION_AUTH" | "NAVIGATION_NOT_FOUND" | "DNS_ERROR" | "NAVIGATION_FAILED" | "SELECTOR_NOT_FOUND" | "PAGE_NOT_AS_EXPECTED" | "SCRIPT_ERROR" | "STYLE_ERROR" | "PDF_GENERATION_FAILED" | "SCREENSHOT_FAILED" | "OUTPUT_WRITE_FAILED" | "INTERNAL_ERROR"]
|
|
11
|
+
|
|
12
|
+
DEFAULT_CODE: "INTERNAL_ERROR"
|
|
13
|
+
|
|
14
|
+
def self.for: (untyped exception) -> untyped
|
|
15
|
+
|
|
16
|
+
# Builds the {code:, message:, retryable:, hint:, details:} hash used as Result#error and in
|
|
17
|
+
# recipe action/assertion failures. Works for any StandardError, not only a Bidi2pdf::Error -
|
|
18
|
+
# an unexpected exception still gets INTERNAL_ERROR with retryable/hint defaulted safely.
|
|
19
|
+
def self.describe: (untyped exception, ?details: ::Hash[untyped, untyped]) -> { code: untyped, message: untyped, retryable: untyped, hint: untyped, details: untyped }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Bidi2pdf
|
|
2
|
+
# Maps an ErrorCodes string to the process exit status the CLI reports. Centralized here so
|
|
3
|
+
# render/diagnose/run share one table instead of each re-deciding what a given failure is worth.
|
|
4
|
+
module ExitCodes
|
|
5
|
+
SUCCESS: 0
|
|
6
|
+
|
|
7
|
+
CLI_ERROR: 2
|
|
8
|
+
|
|
9
|
+
BROWSER_ERROR: 3
|
|
10
|
+
|
|
11
|
+
PAGE_NOT_AS_EXPECTED: 4
|
|
12
|
+
|
|
13
|
+
OUTPUT_ERROR: 6
|
|
14
|
+
|
|
15
|
+
INTERNAL_ERROR: 70
|
|
16
|
+
|
|
17
|
+
CODE_TO_EXIT: { "MISSING_INPUT" => untyped, "MULTIPLE_INPUT_SOURCES" => untyped, "EMPTY_INPUT" => untyped, "INVALID_CONFIG" => untyped, "INVALID_PRINT_OPTION" => untyped, "INVALID_RECIPE" => untyped, "PDF_INSPECTION_UNAVAILABLE" => untyped, "BROWSER_LAUNCH_FAILED" => untyped, "BROWSER_DISCONNECTED" => untyped, "COMMAND_TIMEOUT" => untyped, "NAVIGATION_FAILED" => untyped, "NAVIGATION_TIMEOUT" => untyped, "NAVIGATION_AUTH" => untyped, "NAVIGATION_NOT_FOUND" => untyped, "DNS_ERROR" => untyped, "SELECTOR_NOT_FOUND" => untyped, "PAGE_NOT_AS_EXPECTED" => untyped, "SCRIPT_ERROR" => untyped, "STYLE_ERROR" => untyped, "PDF_GENERATION_FAILED" => untyped, "SCREENSHOT_FAILED" => untyped, "OUTPUT_WRITE_FAILED" => untyped, "INTERNAL_ERROR" => untyped }
|
|
18
|
+
|
|
19
|
+
def self.for: (untyped code) -> untyped
|
|
20
|
+
end
|
|
21
|
+
end
|
data/sig/bidi2pdf/launcher.rbs
CHANGED
|
@@ -62,11 +62,20 @@ module Bidi2pdf
|
|
|
62
62
|
|
|
63
63
|
@chrome_args: untyped
|
|
64
64
|
|
|
65
|
+
@diagnose_runner: untyped
|
|
66
|
+
|
|
65
67
|
# rubocop:disable Metrics/ParameterLists
|
|
66
68
|
def initialize: (url: untyped, inputfile: untyped, output: untyped, cookies: untyped, headers: untyped, auth: untyped, ?headless: bool, ?port: ::Integer, ?wait_window_loaded: bool, ?wait_network_idle: bool, ?print_options: ::Hash[untyped, untyped], ?remote_browser_url: untyped?, ?network_log_format: ::Symbol, ?chrome_args: untyped) -> void
|
|
67
69
|
|
|
68
70
|
def launch: () -> untyped
|
|
69
71
|
|
|
72
|
+
# Like #launch, but navigates only (see SessionRunner#run_diagnose) and leaves the tab open -
|
|
73
|
+
# used by `bidi2pdf diagnose`, which has no PDF to produce. #stop closes it, same as it stops
|
|
74
|
+
# everything else #launch/#diagnose started.
|
|
75
|
+
#
|
|
76
|
+
# @return [Bidi2pdf::Bidi::BrowserTab] the navigated tab.
|
|
77
|
+
def diagnose: () -> untyped
|
|
78
|
+
|
|
70
79
|
def stop: () -> untyped
|
|
71
80
|
|
|
72
81
|
private
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Bidi2pdf
|
|
2
|
+
# Builds the render manifest (--manifest FILE) from a completed Bidi2pdf::Result plus the
|
|
3
|
+
# input/browser/header context around it. Redacts secret-
|
|
4
|
+
# bearing headers and never records cookie values at all.
|
|
5
|
+
class Manifest
|
|
6
|
+
@result: untyped
|
|
7
|
+
|
|
8
|
+
@input: untyped
|
|
9
|
+
|
|
10
|
+
@headers: untyped
|
|
11
|
+
|
|
12
|
+
@navigation_duration_ms: untyped
|
|
13
|
+
|
|
14
|
+
@browser: untyped
|
|
15
|
+
|
|
16
|
+
SCHEMA_VERSION: 1
|
|
17
|
+
|
|
18
|
+
REDACT_HEADER_NAMES: ::Array["authorization" | "proxy-authorization" | "cookie" | "set-cookie" | "x-api-key" | "api-key"]
|
|
19
|
+
|
|
20
|
+
SENSITIVE_SUBSTRINGS: ::Array["token" | "secret" | "password" | "authorization" | "api-key" | "apikey"]
|
|
21
|
+
|
|
22
|
+
def initialize: (result: untyped, input: untyped, ?headers: ::Hash[untyped, untyped], ?navigation_duration_ms: untyped?, ?browser: ::Hash[untyped, untyped]) -> void
|
|
23
|
+
|
|
24
|
+
def self.redact: (untyped headers) -> untyped
|
|
25
|
+
|
|
26
|
+
def self.sensitive?: (untyped key) -> untyped
|
|
27
|
+
|
|
28
|
+
def to_h: () -> untyped
|
|
29
|
+
|
|
30
|
+
def to_json: (*untyped) -> untyped
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def output_section: () -> { path: untyped, pages: untyped, bytes: untyped, sha256: untyped }
|
|
35
|
+
|
|
36
|
+
def navigation_section: () -> untyped
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Bidi2pdf
|
|
2
|
+
module Notifications
|
|
3
|
+
# Emits one JSON object per line to an IO (stderr for --json-stream, docs/specs/llm-friendly-
|
|
4
|
+
# spec.md section 7) as a render progresses, by subscribing to the same Bidi2pdf::Notifications
|
|
5
|
+
# events Bidi2pdf::ResultCollector and LoggingSubscriber already use - no new instrumentation
|
|
6
|
+
# points needed. #emit_result writes the final "result" event once the caller has a Result.
|
|
7
|
+
class JsonSubscriber
|
|
8
|
+
@io: untyped
|
|
9
|
+
|
|
10
|
+
@start: untyped
|
|
11
|
+
|
|
12
|
+
@registered: untyped
|
|
13
|
+
|
|
14
|
+
SCHEMA_VERSION: 1
|
|
15
|
+
|
|
16
|
+
def initialize: (?io: untyped) -> void
|
|
17
|
+
|
|
18
|
+
def emit_result: (untyped result) -> untyped
|
|
19
|
+
|
|
20
|
+
def unsubscribe: () -> untyped
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def now_ms: () -> untyped
|
|
25
|
+
|
|
26
|
+
def elapsed_ms: () -> untyped
|
|
27
|
+
|
|
28
|
+
def subscribe: () -> untyped
|
|
29
|
+
|
|
30
|
+
def on_navigate: (untyped event) -> untyped
|
|
31
|
+
|
|
32
|
+
def on_page_loaded: (untyped _event) -> untyped
|
|
33
|
+
|
|
34
|
+
def on_network_idle: (untyped _event) -> untyped
|
|
35
|
+
|
|
36
|
+
def on_console: (untyped event) -> untyped
|
|
37
|
+
|
|
38
|
+
def on_print: (untyped _event) -> untyped
|
|
39
|
+
|
|
40
|
+
def on_screenshot: (untyped _event) -> untyped
|
|
41
|
+
|
|
42
|
+
def write: (untyped fields) -> untyped
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Bidi2pdf
|
|
2
|
+
# Lazy, optional wrapper around the pdf-reader gem. pdf-reader is deliberately NOT a bidi2pdf
|
|
3
|
+
# runtime dependency - render works fine without it,
|
|
4
|
+
# just with `pages` left nil and a warning, and a recipe assertion that needs it fails
|
|
5
|
+
# INVALID_RECIPE at validation time rather than crashing mid-render. The official Docker images
|
|
6
|
+
# (docker/Dockerfile, docker/Dockerfile.slim) install it, so a render inside one of them - the
|
|
7
|
+
# environment an agent or CI job actually uses - always has page counts and PDF assertions
|
|
8
|
+
# available without anyone opting in by hand.
|
|
9
|
+
module PdfInspection
|
|
10
|
+
self.@available: untyped
|
|
11
|
+
|
|
12
|
+
def self.available?: () -> untyped
|
|
13
|
+
|
|
14
|
+
# @param bytes [String, nil] raw PDF bytes.
|
|
15
|
+
# @return [Integer, nil] page count, or nil when pdf-reader is unavailable, bytes is nil,
|
|
16
|
+
# or the PDF is malformed.
|
|
17
|
+
def self.page_count: (untyped bytes) -> untyped
|
|
18
|
+
|
|
19
|
+
# @param bytes [String, nil] raw PDF bytes.
|
|
20
|
+
# @return [String, nil] extracted text across all pages, joined with newlines.
|
|
21
|
+
def self.text: (untyped bytes) -> untyped
|
|
22
|
+
|
|
23
|
+
# Clears the memoized availability check - test-only, so a spec can exercise both branches
|
|
24
|
+
# of #available? regardless of load order elsewhere in the process.
|
|
25
|
+
def self.reset_for_testing!: () -> (untyped | nil)
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def self.load_pdf_reader: () -> untyped
|
|
30
|
+
|
|
31
|
+
def self.reader_for: (untyped bytes) -> untyped
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Bidi2pdf
|
|
2
|
+
class Recipe
|
|
3
|
+
# Reads a recipe file (YAML, or JSON when the extension is .json) into a plain Hash. Never
|
|
4
|
+
# launches a browser - a malformed file fails right here.
|
|
5
|
+
#
|
|
6
|
+
# Security: YAML is parsed with Psych.safe_load and no extra options. Its defaults already
|
|
7
|
+
# refuse both custom type tags (no arbitrary Ruby object instantiation) and aliases - the
|
|
8
|
+
# latter matters concretely: a small file using nested YAML anchors/aliases ("billion laughs")
|
|
9
|
+
# can expand to millions of elements in memory; confirmed live, a 273-byte such file produced
|
|
10
|
+
# 10 million array elements in under 2ms. Neither aliases nor a Date/Time value are used by any
|
|
11
|
+
# field a recipe defines, so neither is turned back on "just in case" - only what the format
|
|
12
|
+
# actually needs is permitted. MAX_BYTES bounds the file itself before it is even parsed.
|
|
13
|
+
module Loader
|
|
14
|
+
MAX_BYTES: 1048576
|
|
15
|
+
|
|
16
|
+
# rubocop:disable-next Metrics/AbcSize
|
|
17
|
+
def self.load: (untyped path) -> untyped
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
module Bidi2pdf
|
|
2
|
+
class Recipe
|
|
3
|
+
# Runs a validated recipe's actions and assertions against a navigated tab. A thin dispatch
|
|
4
|
+
# over BrowserTab's own methods, not a parallel action/assertion class hierarchy - a step
|
|
5
|
+
# that needs something BrowserTab cannot do belongs there, not here.
|
|
6
|
+
class Runner
|
|
7
|
+
@recipe: untyped
|
|
8
|
+
|
|
9
|
+
@tab: untyped
|
|
10
|
+
|
|
11
|
+
@collector: untyped
|
|
12
|
+
|
|
13
|
+
@state: untyped
|
|
14
|
+
|
|
15
|
+
@pdf_bytes: untyped
|
|
16
|
+
|
|
17
|
+
# Raised on the first failing action/assertion; #entries carries every step run so far
|
|
18
|
+
# (successes and the failure itself), for the caller to report in the recipe result.
|
|
19
|
+
class StepFailure < StandardError
|
|
20
|
+
@entries: untyped
|
|
21
|
+
|
|
22
|
+
@cause: untyped
|
|
23
|
+
|
|
24
|
+
attr_reader entries: untyped
|
|
25
|
+
|
|
26
|
+
attr_reader cause: untyped
|
|
27
|
+
|
|
28
|
+
def initialize: (untyped entries, untyped cause) -> void
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attr_reader state: untyped
|
|
32
|
+
|
|
33
|
+
attr_accessor pdf_bytes: untyped
|
|
34
|
+
|
|
35
|
+
# @param recipe [Bidi2pdf::Recipe]
|
|
36
|
+
# @param tab [Bidi2pdf::Bidi::BrowserTab] a navigated tab (e.g. from Launcher#diagnose)
|
|
37
|
+
# @param collector [Bidi2pdf::ResultCollector] source of #console/#network_failures -
|
|
38
|
+
# exposed live, so assertions can read them mid-run (see ResultCollector's own docs)
|
|
39
|
+
def initialize: (recipe: untyped, tab: untyped, collector: untyped) -> void
|
|
40
|
+
|
|
41
|
+
# @return [Array<Hash>] one entry per action actually run
|
|
42
|
+
# @raise [StepFailure] on the first failing action
|
|
43
|
+
def run_actions: () -> untyped
|
|
44
|
+
|
|
45
|
+
# @return [Array<Hash>] one entry per assertion actually run
|
|
46
|
+
# @raise [StepFailure] on the first failing assertion
|
|
47
|
+
def run_assertions: () -> untyped
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# rubocop:disable-next Metrics/AbcSize
|
|
52
|
+
def run_steps: (untyped steps) { (untyped, untyped) -> untyped } -> untyped
|
|
53
|
+
|
|
54
|
+
def now_ms: () -> untyped
|
|
55
|
+
|
|
56
|
+
def elapsed_ms: (untyped start) -> untyped
|
|
57
|
+
|
|
58
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity
|
|
59
|
+
def dispatch_action: (untyped name, untyped opts) -> untyped
|
|
60
|
+
|
|
61
|
+
def action_wait_for: (untyped opts) -> (nil | untyped)
|
|
62
|
+
|
|
63
|
+
def wait_for_condition: (untyped opts) -> ("document.querySelector('.pagedjs_page')" | ::String | untyped)
|
|
64
|
+
|
|
65
|
+
def action_click: (untyped opts) -> (nil | untyped)
|
|
66
|
+
|
|
67
|
+
def action_evaluate: (untyped opts) -> untyped
|
|
68
|
+
|
|
69
|
+
# rubocop:disable-next Metrics/CyclomaticComplexity
|
|
70
|
+
def dispatch_assertion: (untyped name, untyped step) -> untyped
|
|
71
|
+
|
|
72
|
+
def assertion_selector_exists: (untyped opts) -> ::Array[untyped | { selector: untyped }]
|
|
73
|
+
|
|
74
|
+
def assertion_text_present: (untyped opts) -> ::Array[untyped | { text: untyped }]
|
|
75
|
+
|
|
76
|
+
def assertion_no_console_errors: () -> ::Array[untyped]
|
|
77
|
+
|
|
78
|
+
# Filtering by resource type (a `types:` option) is not implemented: Bidi2pdf::Bidi::
|
|
79
|
+
# NetworkEvent carries an HTTP method, not a resource type, so there is nothing to filter
|
|
80
|
+
# by yet. Every captured failure counts.
|
|
81
|
+
def assertion_no_network_failures: () -> ::Array[untyped]
|
|
82
|
+
|
|
83
|
+
def assertion_fonts_loaded: () -> ::Array[untyped | nil]
|
|
84
|
+
|
|
85
|
+
def assertion_page_count: (untyped expected) -> ::Array[untyped | { expected: untyped, actual: untyped }]
|
|
86
|
+
|
|
87
|
+
def assertion_pdf_text_present: (untyped opts) -> ::Array[untyped | { text: untyped }]
|
|
88
|
+
|
|
89
|
+
def assertion_pdf_not_blank: () -> ::Array[untyped | { page_count: untyped, text_present: untyped }]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
module Bidi2pdf
|
|
2
|
+
class Recipe
|
|
3
|
+
# Structural validation against Schema::RECIPE itself, not a hand-duplicated parallel list of
|
|
4
|
+
# rules. Validator's other #check_* methods each enforce one *semantic* rule (an action name
|
|
5
|
+
# is known, a source key is truthy, a presence assertion is `true`) with a friendly, specific
|
|
6
|
+
# message - none of them enforce the schema's own `additionalProperties: false` at every
|
|
7
|
+
# level, so an extra, unrecognized key sitting alongside an otherwise-valid one used to pass
|
|
8
|
+
# silently even though `bidi2pdf schema recipe` would reject it (`{url: "...", stdin: false}`,
|
|
9
|
+
# `{wait_for: {...}, extra: 1}` - two action names in one step, `wait_for: {selector: "#x",
|
|
10
|
+
# bogus: "y"}`). #check_shape (see Validator) closes that whole class of gap generically by
|
|
11
|
+
# walking the schema the CLI already publishes, instead of re-describing "what's allowed" a
|
|
12
|
+
# second time in Ruby - the schema is the one place that description can't drift from itself.
|
|
13
|
+
#
|
|
14
|
+
# Deliberately not a general JSON Schema engine and not a gem dependency: only the keywords
|
|
15
|
+
# Schema::RECIPE actually uses (type, const, enum, required, additionalProperties, properties,
|
|
16
|
+
# oneOf, anyOf, items, minimum, maximum). Extend the keyword list only if a future schema
|
|
17
|
+
# branch genuinely needs one this doesn't cover yet.
|
|
18
|
+
module SchemaShape
|
|
19
|
+
TYPE_CHECKS: { "object" => untyped, "array" => untyped, "string" => untyped, "integer" => untyped, "number" => untyped, "boolean" => untyped, "null" => untyped }
|
|
20
|
+
|
|
21
|
+
CHECKS: ::Array[:const_ok? | :enum_ok? | :type_ok? | :one_of_ok? | :any_of_ok? | :required_ok? | :additional_properties_ok? | :properties_ok? | :items_ok? | :bounds_ok?]
|
|
22
|
+
|
|
23
|
+
# @return [Boolean] whether value satisfies schema
|
|
24
|
+
def self?.matches?: (untyped schema, untyped value) -> untyped
|
|
25
|
+
|
|
26
|
+
VIOLATION_CHECKS: ::Array[:oneof_violation | :anyof_violation | :required_violation | :additional_properties_violation | :const_violation | :enum_violation | :type_violation | :bounds_violation | :nested_violation]
|
|
27
|
+
|
|
28
|
+
# @return [Hash, nil] {path:, reason:} for the first violation a depth-first walk finds, or
|
|
29
|
+
# nil if value already satisfies schema. Not necessarily *the* most relevant violation
|
|
30
|
+
# when several exist at once - good enough to point an agent at the right neighborhood,
|
|
31
|
+
# not a substitute for reading `bidi2pdf schema recipe` for the exact shape.
|
|
32
|
+
def self?.first_violation: (untyped schema, untyped value, ?untyped path) -> (nil | untyped | { path: untyped, reason: "does not match the schema" })
|
|
33
|
+
|
|
34
|
+
def self?.const_ok?: (untyped schema, untyped value) -> untyped
|
|
35
|
+
|
|
36
|
+
def self?.enum_ok?: (untyped schema, untyped value) -> untyped
|
|
37
|
+
|
|
38
|
+
def self?.type_ok?: (untyped schema, untyped value) -> (true | untyped)
|
|
39
|
+
|
|
40
|
+
def self?.one_of_ok?: (untyped schema, untyped value) -> (true | untyped)
|
|
41
|
+
|
|
42
|
+
def self?.any_of_ok?: (untyped schema, untyped value) -> (true | untyped)
|
|
43
|
+
|
|
44
|
+
def self?.required_ok?: (untyped schema, untyped value) -> (true | untyped)
|
|
45
|
+
|
|
46
|
+
def self?.additional_properties_ok?: (untyped schema, untyped value) -> (true | untyped)
|
|
47
|
+
|
|
48
|
+
def self?.properties_ok?: (untyped schema, untyped value) -> (true | untyped)
|
|
49
|
+
|
|
50
|
+
def self?.items_ok?: (untyped schema, untyped value) -> (true | untyped)
|
|
51
|
+
|
|
52
|
+
def self?.bounds_ok?: (untyped schema, untyped value) -> (true | untyped)
|
|
53
|
+
|
|
54
|
+
def self?.display_path: (untyped path) -> untyped
|
|
55
|
+
|
|
56
|
+
# When zero branches match but exactly one is "plausible" (its own required key(s) are
|
|
57
|
+
# present, ignoring additionalProperties), recurse into that one branch for a specific
|
|
58
|
+
# reason/path instead of a bare "matched 0 of N" - this is what turns "actions[0] doesn't
|
|
59
|
+
# match any known action" into "actions[0].wait_for: unknown key(s): bogus".
|
|
60
|
+
def self?.oneof_violation: (untyped schema, untyped value, untyped path) -> (nil | untyped)
|
|
61
|
+
|
|
62
|
+
def self?.single_plausible_violation: (untyped schema, untyped value, untyped path) -> (nil | untyped)
|
|
63
|
+
|
|
64
|
+
def self?.anyof_violation: (untyped schema, untyped value, untyped path) -> (nil | { path: untyped, reason: ::String })
|
|
65
|
+
|
|
66
|
+
def self?.required_violation: (untyped schema, untyped value, untyped path) -> (nil | { path: untyped, reason: ::String })
|
|
67
|
+
|
|
68
|
+
def self?.additional_properties_violation: (untyped schema, untyped value, untyped path) -> (nil | { path: untyped, reason: ::String })
|
|
69
|
+
|
|
70
|
+
def self?.const_violation: (untyped schema, untyped value, untyped path) -> (nil | { path: untyped, reason: ::String })
|
|
71
|
+
|
|
72
|
+
def self?.enum_violation: (untyped schema, untyped value, untyped path) -> (nil | { path: untyped, reason: ::String })
|
|
73
|
+
|
|
74
|
+
def self?.type_violation: (untyped schema, untyped value, untyped path) -> (nil | { path: untyped, reason: ::String })
|
|
75
|
+
|
|
76
|
+
def self?.bounds_violation: (untyped schema, untyped value, untyped path) -> (nil | { path: untyped, reason: ::String })
|
|
77
|
+
|
|
78
|
+
def self?.nested_violation: (untyped schema, untyped value, untyped path) -> untyped
|
|
79
|
+
|
|
80
|
+
def self?.properties_violation: (untyped schema, untyped value, untyped path) -> (nil | untyped)
|
|
81
|
+
|
|
82
|
+
def self?.items_violation: (untyped schema, untyped value, untyped path) -> (nil | untyped)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Bidi2pdf
|
|
2
|
+
class Recipe
|
|
3
|
+
# Validates a loaded recipe against the schema `bidi2pdf schema recipe` publishes, before any
|
|
4
|
+
# browser is launched - `bidi2pdf run --validate` runs exactly this and nothing else. Every
|
|
5
|
+
# failure is Bidi2pdf::InvalidRecipeError (or Bidi2pdf::PdfInspectionUnavailableError for a
|
|
6
|
+
# PDF assertion without pdf-reader installed), details carrying a `path` an agent can act on
|
|
7
|
+
# directly.
|
|
8
|
+
class Validator
|
|
9
|
+
@recipe: untyped
|
|
10
|
+
|
|
11
|
+
def initialize: (untyped recipe) -> void
|
|
12
|
+
|
|
13
|
+
def validate!: () -> untyped
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def check_version: () -> (nil | untyped)
|
|
18
|
+
|
|
19
|
+
def check_top_level_keys: () -> (nil | untyped)
|
|
20
|
+
|
|
21
|
+
def check_source: () -> untyped
|
|
22
|
+
|
|
23
|
+
def check_steps: (untyped section, untyped steps, untyped known) -> untyped
|
|
24
|
+
|
|
25
|
+
# Mirrors Schema::RECIPE_ACTIONS' own wait_for oneOf: exactly one of selector/paged_js/script.
|
|
26
|
+
# --validate runs this check itself rather than relying on Runner#wait_for_condition, whose
|
|
27
|
+
# own "needs one of" error only fires mid-run (after a browser is already launched) and does
|
|
28
|
+
# not catch "more than one given" at all - it just silently prefers paged_js, then selector.
|
|
29
|
+
def check_wait_for_conditions: () -> untyped
|
|
30
|
+
|
|
31
|
+
# Mirrors Schema::RECIPE_ASSERT's own const: true for the 4 presence-only assertions -
|
|
32
|
+
# Runner never reads the value beside them, so anything but `true` is misleading rather than
|
|
33
|
+
# merely unusual (see Recipe::PRESENCE_ONLY_ASSERTIONS' own comment).
|
|
34
|
+
def check_presence_assertions: () -> untyped
|
|
35
|
+
|
|
36
|
+
def check_pdf_assertions_need_pdf_inspection: () -> (nil | untyped)
|
|
37
|
+
|
|
38
|
+
def check_output: () -> (nil | untyped)
|
|
39
|
+
|
|
40
|
+
def check_print_options: () -> untyped
|
|
41
|
+
|
|
42
|
+
def symbolize: (untyped hash) -> untyped
|
|
43
|
+
|
|
44
|
+
# Runs last, deliberately: everything above gives a friendlier, more specific message for
|
|
45
|
+
# the case it already knows about (an unknown action name, a non-true presence assertion, a
|
|
46
|
+
# real PrintParametersValidator error, ...), so this only ever fires for a shape violation
|
|
47
|
+
# none of them catches - an extra/unrecognized key sitting alongside an otherwise-valid one
|
|
48
|
+
# (SchemaShape's own doc comment has the concrete examples this closes).
|
|
49
|
+
def check_shape: () -> (nil | untyped)
|
|
50
|
+
|
|
51
|
+
def fail!: (untyped reason, path: untyped) -> untyped
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module Bidi2pdf
|
|
2
|
+
# A recipe is a rendering contract: the waits and preparation a page needs before it is printed,
|
|
3
|
+
# and the properties the resulting PDF must have - not a general browser-automation script.
|
|
4
|
+
# `bidi2pdf run recipe.yml` is Loader -> (an invalid recipe stops here, no browser ever
|
|
5
|
+
# launches) -> Validator -> Runner.
|
|
6
|
+
class Recipe
|
|
7
|
+
@data: untyped
|
|
8
|
+
|
|
9
|
+
@path: untyped
|
|
10
|
+
|
|
11
|
+
KNOWN_TOP_LEVEL_KEYS: ::Array["version" | "source" | "browser" | "headers" | "cookies" | "auth" | "actions" | "assert" | "print" | "output"]
|
|
12
|
+
|
|
13
|
+
KNOWN_ACTIONS: ::Array["wait_for" | "click" | "evaluate" | "inject_script" | "inject_style" | "set_viewport" | "wait_network_idle"]
|
|
14
|
+
|
|
15
|
+
PAGE_ASSERTIONS: ::Array["selector_exists" | "text_present" | "no_console_errors" | "no_network_failures" | "fonts_loaded"]
|
|
16
|
+
|
|
17
|
+
PDF_ASSERTIONS: ::Array["page_count" | "pdf_text_present" | "pdf_not_blank"]
|
|
18
|
+
|
|
19
|
+
KNOWN_ASSERTIONS: untyped
|
|
20
|
+
|
|
21
|
+
KNOWN_OUTPUTS: ::Array["pdf" | "manifest" | "screenshot"]
|
|
22
|
+
|
|
23
|
+
# These four assertions are pure presence checks - Runner never reads the value beside them
|
|
24
|
+
# (see #assertion_no_console_errors and friends), so a bare key is really what's being
|
|
25
|
+
# asserted. `false`/anything but `true` is therefore misleading, not merely unusual: schema
|
|
26
|
+
# recipe encodes this as `const: true` and Validator#check_presence_assertions enforces it too.
|
|
27
|
+
PRESENCE_ONLY_ASSERTIONS: ::Array["no_console_errors" | "no_network_failures" | "fonts_loaded" | "pdf_not_blank"]
|
|
28
|
+
|
|
29
|
+
attr_reader data: untyped
|
|
30
|
+
|
|
31
|
+
attr_reader path: untyped
|
|
32
|
+
|
|
33
|
+
def self.load: (untyped path) -> untyped
|
|
34
|
+
|
|
35
|
+
def initialize: (untyped data, ?path: untyped?) -> void
|
|
36
|
+
|
|
37
|
+
def validate!: () -> untyped
|
|
38
|
+
|
|
39
|
+
def actions: () -> untyped
|
|
40
|
+
|
|
41
|
+
def assertions: () -> untyped
|
|
42
|
+
|
|
43
|
+
def source: () -> untyped
|
|
44
|
+
|
|
45
|
+
def browser_options: () -> untyped
|
|
46
|
+
|
|
47
|
+
def headers: () -> untyped
|
|
48
|
+
|
|
49
|
+
def cookies: () -> untyped
|
|
50
|
+
|
|
51
|
+
def auth: () -> untyped
|
|
52
|
+
|
|
53
|
+
def print_options: () -> untyped
|
|
54
|
+
|
|
55
|
+
def output: () -> untyped
|
|
56
|
+
|
|
57
|
+
def needs_pdf?: () -> untyped
|
|
58
|
+
|
|
59
|
+
def self.step_name: (untyped step) -> untyped
|
|
60
|
+
|
|
61
|
+
def step_name: (untyped step) -> untyped
|
|
62
|
+
|
|
63
|
+
# The value beside a step's name - a Hash of options (`wait_for: {selector: ..., timeout:
|
|
64
|
+
# ...}`), a bare scalar (`page_count: 2`, `no_console_errors: true`), or nil.
|
|
65
|
+
def step_value: (untyped step) -> (untyped | nil)
|
|
66
|
+
|
|
67
|
+
# step_value coerced to a Hash of options - most actions/assertions take this shape; a bare
|
|
68
|
+
# scalar step (page_count: 2, no_console_errors: true) has no options of its own to offer here,
|
|
69
|
+
# so callers that need the scalar itself use #step_value directly.
|
|
70
|
+
def step_options: (untyped step) -> untyped
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module Bidi2pdf
|
|
2
|
+
# Structured result of a CLI command (render/diagnose/run) - the shape behind --json output, a
|
|
3
|
+
# render manifest, and the final "result" event of --json-stream. Construct via .success/
|
|
4
|
+
# .failure rather than .new directly so every Result is unambiguously one or the other.
|
|
5
|
+
class Result
|
|
6
|
+
@command: untyped
|
|
7
|
+
|
|
8
|
+
@ok: untyped
|
|
9
|
+
|
|
10
|
+
@output: untyped
|
|
11
|
+
|
|
12
|
+
@bytes: untyped
|
|
13
|
+
|
|
14
|
+
@sha256: untyped
|
|
15
|
+
|
|
16
|
+
@pages: untyped
|
|
17
|
+
|
|
18
|
+
@duration_ms: untyped
|
|
19
|
+
|
|
20
|
+
@navigation: untyped
|
|
21
|
+
|
|
22
|
+
@console: untyped
|
|
23
|
+
|
|
24
|
+
@network_failures: untyped
|
|
25
|
+
|
|
26
|
+
@warnings: untyped
|
|
27
|
+
|
|
28
|
+
@error: untyped
|
|
29
|
+
|
|
30
|
+
@metadata: untyped
|
|
31
|
+
|
|
32
|
+
SCHEMA_VERSION: 1
|
|
33
|
+
|
|
34
|
+
attr_reader command: untyped
|
|
35
|
+
|
|
36
|
+
attr_reader output: untyped
|
|
37
|
+
|
|
38
|
+
attr_reader bytes: untyped
|
|
39
|
+
|
|
40
|
+
attr_reader sha256: untyped
|
|
41
|
+
|
|
42
|
+
attr_reader pages: untyped
|
|
43
|
+
|
|
44
|
+
attr_reader duration_ms: untyped
|
|
45
|
+
|
|
46
|
+
attr_reader navigation: untyped
|
|
47
|
+
|
|
48
|
+
attr_reader console: untyped
|
|
49
|
+
|
|
50
|
+
attr_reader network_failures: untyped
|
|
51
|
+
|
|
52
|
+
attr_reader warnings: untyped
|
|
53
|
+
|
|
54
|
+
attr_reader error: untyped
|
|
55
|
+
|
|
56
|
+
attr_reader metadata: untyped
|
|
57
|
+
|
|
58
|
+
# rubocop:disable-next Metrics/ParameterLists, Naming/MethodParameterName
|
|
59
|
+
def initialize: (command: untyped, ok: untyped, ?output: untyped?, ?bytes: untyped?, ?sha256: untyped?, ?pages: untyped?, ?duration_ms: untyped?, ?navigation: untyped?, ?console: untyped, ?network_failures: untyped, ?warnings: untyped, ?error: untyped?, ?metadata: ::Hash[untyped, untyped]) -> void
|
|
60
|
+
|
|
61
|
+
def self.success: (**untyped) -> untyped
|
|
62
|
+
|
|
63
|
+
def self.failure: (error: untyped, **untyped) -> untyped
|
|
64
|
+
|
|
65
|
+
def ok?: () -> untyped
|
|
66
|
+
|
|
67
|
+
def to_h: () -> untyped
|
|
68
|
+
|
|
69
|
+
def to_json: (*untyped) -> untyped
|
|
70
|
+
end
|
|
71
|
+
end
|