bidi2pdf 0.1.15 → 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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +20 -0
  3. data/CHANGELOG.md +22 -2
  4. data/README.md +196 -11
  5. data/docker/Dockerfile +4 -0
  6. data/docker/Dockerfile.slim +5 -1
  7. data/lib/bidi2pdf/cli/json_output.rb +30 -0
  8. data/lib/bidi2pdf/cli.rb +559 -17
  9. data/lib/bidi2pdf/diagnose.rb +119 -0
  10. data/lib/bidi2pdf/error_codes.rb +59 -0
  11. data/lib/bidi2pdf/exit_codes.rb +44 -0
  12. data/lib/bidi2pdf/launcher.rb +23 -0
  13. data/lib/bidi2pdf/manifest.rb +65 -0
  14. data/lib/bidi2pdf/notifications/json_subscriber.rb +78 -0
  15. data/lib/bidi2pdf/pdf_inspection.rb +62 -0
  16. data/lib/bidi2pdf/recipe/loader.rb +44 -0
  17. data/lib/bidi2pdf/recipe/runner.rb +229 -0
  18. data/lib/bidi2pdf/recipe/schema_shape.rb +228 -0
  19. data/lib/bidi2pdf/recipe/validator.rb +138 -0
  20. data/lib/bidi2pdf/recipe.rb +83 -0
  21. data/lib/bidi2pdf/result.rb +67 -0
  22. data/lib/bidi2pdf/result_collector.rb +150 -0
  23. data/lib/bidi2pdf/schema.rb +382 -0
  24. data/lib/bidi2pdf/session_runner.rb +42 -0
  25. data/lib/bidi2pdf/version.rb +1 -1
  26. data/lib/bidi2pdf.rb +90 -8
  27. data/sig/bidi2pdf/cli/json_output.rbs +19 -0
  28. data/sig/bidi2pdf/cli.rbs +112 -2
  29. data/sig/bidi2pdf/diagnose.rbs +30 -0
  30. data/sig/bidi2pdf/error_codes.rbs +21 -0
  31. data/sig/bidi2pdf/exit_codes.rbs +21 -0
  32. data/sig/bidi2pdf/launcher.rbs +9 -0
  33. data/sig/bidi2pdf/manifest.rbs +38 -0
  34. data/sig/bidi2pdf/notifications/json_subscriber.rbs +45 -0
  35. data/sig/bidi2pdf/pdf_inspection.rbs +33 -0
  36. data/sig/bidi2pdf/recipe/loader.rbs +20 -0
  37. data/sig/bidi2pdf/recipe/runner.rbs +92 -0
  38. data/sig/bidi2pdf/recipe/schema_shape.rbs +85 -0
  39. data/sig/bidi2pdf/recipe/validator.rbs +54 -0
  40. data/sig/bidi2pdf/recipe.rbs +72 -0
  41. data/sig/bidi2pdf/result.rbs +71 -0
  42. data/sig/bidi2pdf/result_collector.rbs +106 -0
  43. data/sig/bidi2pdf/schema.rbs +45 -0
  44. data/sig/bidi2pdf/session_runner.rbs +17 -0
  45. data/sig/bidi2pdf/version.rbs +1 -1
  46. data/sig/bidi2pdf.rbs +66 -0
  47. metadata +31 -1
@@ -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
@@ -0,0 +1,106 @@
1
+ module Bidi2pdf
2
+ # Builds a Bidi2pdf::Result by subscribing to the gem's own instrumentation events
3
+ # (Bidi2pdf::Notifications) around a block that does the actual rendering, rather than changing
4
+ # what Launcher/SessionRunner/BrowserTab return - Launcher#launch keeps returning the raw base64
5
+ # PDF when no output file is given, exactly as the programmatic API already promises (see
6
+ # spec/acceptance/launcher_spec.rb).
7
+ class ResultCollector
8
+ @requested_url: untyped
9
+
10
+ @command: untyped
11
+
12
+ @output: untyped
13
+
14
+ @console: untyped
15
+
16
+ @network_events: untyped
17
+
18
+ @pdf_bytes: untyped
19
+
20
+ # Capture the Proc #subscribe actually stored (it converts &handler into one), so
21
+ # #unsubscribe below can remove exactly this registration - passing the handler positionally
22
+ # rather than as a second &block, since Notifications#unsubscribe(pattern, block = nil)
23
+ # clears *every* subscriber for that pattern when block is nil/omitted.
24
+ @registered: untyped
25
+
26
+ @navigation_event: untyped
27
+
28
+ # #pdf_bytes is the raw, decoded PDF (nil if nothing was printed) - not part of Result#to_h
29
+ # (which only carries bytesize/sha256/pages), but what a stdout-output render (--output -)
30
+ # writes to $stdout, and what a caller can hand to Bidi2pdf::PdfInspection itself.
31
+ #
32
+ # #console/#network_failures are exposed live (not only via the Result #around eventually
33
+ # returns) so a caller running inside the block - Recipe::Runner's assertions - can read them
34
+ # before the block itself finishes.
35
+ attr_reader requested_url: untyped
36
+
37
+ # #pdf_bytes is the raw, decoded PDF (nil if nothing was printed) - not part of Result#to_h
38
+ # (which only carries bytesize/sha256/pages), but what a stdout-output render (--output -)
39
+ # writes to $stdout, and what a caller can hand to Bidi2pdf::PdfInspection itself.
40
+ #
41
+ # #console/#network_failures are exposed live (not only via the Result #around eventually
42
+ # returns) so a caller running inside the block - Recipe::Runner's assertions - can read them
43
+ # before the block itself finishes.
44
+ attr_reader navigation_event: untyped
45
+
46
+ # #pdf_bytes is the raw, decoded PDF (nil if nothing was printed) - not part of Result#to_h
47
+ # (which only carries bytesize/sha256/pages), but what a stdout-output render (--output -)
48
+ # writes to $stdout, and what a caller can hand to Bidi2pdf::PdfInspection itself.
49
+ #
50
+ # #console/#network_failures are exposed live (not only via the Result #around eventually
51
+ # returns) so a caller running inside the block - Recipe::Runner's assertions - can read them
52
+ # before the block itself finishes.
53
+ attr_reader pdf_bytes: untyped
54
+
55
+ # #pdf_bytes is the raw, decoded PDF (nil if nothing was printed) - not part of Result#to_h
56
+ # (which only carries bytesize/sha256/pages), but what a stdout-output render (--output -)
57
+ # writes to $stdout, and what a caller can hand to Bidi2pdf::PdfInspection itself.
58
+ #
59
+ # #console/#network_failures are exposed live (not only via the Result #around eventually
60
+ # returns) so a caller running inside the block - Recipe::Runner's assertions - can read them
61
+ # before the block itself finishes.
62
+ attr_reader console: untyped
63
+
64
+ def initialize: (requested_url: untyped, command: untyped, ?output: untyped?) -> void
65
+
66
+ # Runs the block with the collector subscribed, then returns a Bidi2pdf::Result - success with
67
+ # whatever was captured, or failure describing whatever the block raised. Never itself raises.
68
+ def around: () { () -> untyped } -> untyped
69
+
70
+ def network_failures: () -> untyped
71
+
72
+ private
73
+
74
+ def now_ms: () -> untyped
75
+
76
+ def elapsed_ms: (untyped start) -> untyped
77
+
78
+ def subscribe: () -> untyped
79
+
80
+ def unsubscribe: () -> untyped
81
+
82
+ # Kept for its #duration (used by Manifest's navigation.duration_ms); Result itself doesn't
83
+ # surface navigation timing separately from the overall duration_ms.
84
+ def on_navigate: (untyped event) -> untyped
85
+
86
+ def on_console: (untyped event) -> untyped
87
+
88
+ def on_network_event: (untyped event) -> (nil | untyped)
89
+
90
+ def on_print: (untyped event) -> untyped
91
+
92
+ # rubocop:disable-next Naming/MethodParameterName
93
+ def build_result: (ok: untyped, duration_ms: untyped, ?error: untyped?) -> untyped
94
+
95
+ def navigation_info: () -> { requested_url: untyped, final_url: untyped, status: untyped }
96
+
97
+ # Best-effort external approximation of what BrowserTab#send(:correlated_navigation_response)
98
+ # already computes internally (not exposed publicly): among the network events this collector
99
+ # observed, the ones carrying a real BiDi "navigation" id are main-frame document requests: the
100
+ # one with the latest start_timestamp among those sharing the most recent such navigation id is
101
+ # the final hop of that navigation, and its own #url is therefore the page actually reached.
102
+ def final_navigation_event: () -> (nil | untyped)
103
+
104
+ def warnings: () -> (::Array[untyped] | ::Array["pages requires the pdf-reader gem"])
105
+ end
106
+ end
@@ -0,0 +1,45 @@
1
+ module Bidi2pdf
2
+ # JSON Schema documents behind `bidi2pdf schema <kind>` - how an agent discovers the shape of
3
+ # --json/--manifest/--json-stream/a recipe file without reading prose documentation. These are
4
+ # the source of truth for those shapes; a change to Result/Diagnose's payload/Recipe::Runner's
5
+ # result shape/Manifest/JsonSubscriber/Recipe's own fields should update the matching schema
6
+ # here in the same change. Kind names match the `command` field a response already carries
7
+ # (`"command": "diagnose"` -> `bidi2pdf schema diagnose`), not the Ruby class names behind them.
8
+ module Schema
9
+ ERROR: { "type" => ::Array["object" | "null"], "required" => ::Array["code" | "message" | "retryable"], "properties" => { "code" => { "type" => "string" }, "message" => { "type" => "string" }, "retryable" => { "type" => "boolean" }, "hint" => { "type" => ::Array["string" | "null"] }, "details" => { "type" => "object" } } }
10
+
11
+ NAVIGATION: { "type" => ::Array["object" | "null"], "properties" => { "requested_url" => { "type" => ::Array["string" | "null"] }, "final_url" => { "type" => ::Array["string" | "null"] }, "status" => { "type" => ::Array["integer" | "null"] } } }
12
+
13
+ CONSOLE: { "type" => "array", "items" => { "type" => "object", "properties" => { "level" => { "type" => "string" }, "text" => { "type" => "string" } } } }
14
+
15
+ NETWORK_FAILURES: { "type" => "array", "items" => { "type" => "object", "properties" => { "url" => { "type" => "string" }, "method" => { "type" => ::Array["string" | "null"] }, "status" => { "type" => ::Array["integer" | "null"] }, "state" => { "type" => "string" } } } }
16
+
17
+ WARNINGS: { "type" => "array", "items" => { "type" => "string" } }
18
+
19
+ RENDER: { "$schema" => "https://json-schema.org/draft/2020-12/schema", "title" => "Bidi2pdf::Result (render)", "description" => "--json output of `bidi2pdf render`, and the final \"result\" event of its --json-stream", "type" => "object", "required" => ::Array["schema_version" | "ok" | "command" | "console" | "network_failures" | "warnings"], "properties" => { "schema_version" => { "const" => 1 }, "ok" => { "type" => "boolean" }, "command" => { "const" => "render" }, "output" => { "type" => ::Array["string" | "null"] }, "bytes" => { "type" => ::Array["integer" | "null"] }, "sha256" => { "type" => ::Array["string" | "null"] }, "pages" => { "type" => ::Array["integer" | "null"], "description" => "null when the pdf-reader gem is unavailable" }, "duration_ms" => { "type" => ::Array["integer" | "null"] }, "navigation" => untyped, "console" => untyped, "network_failures" => untyped, "warnings" => untyped, "error" => untyped } }
20
+
21
+ DIAGNOSE: { "$schema" => "https://json-schema.org/draft/2020-12/schema", "title" => "bidi2pdf diagnose result", "description" => "--json output of `bidi2pdf diagnose`, and the final \"result\" event of its --json-stream. page/fonts/print_media/paged_js are Bidi2pdf::Diagnose's own parsed script results, loosely shaped here rather than over-specified.", "type" => "object", "required" => ::Array["schema_version" | "ok" | "command" | "console" | "network_failures" | "warnings"], "properties" => { "schema_version" => { "const" => 1 }, "ok" => { "type" => "boolean" }, "command" => { "const" => "diagnose" }, "page" => { "type" => ::Array["object" | "null"] }, "console" => untyped, "network_failures" => untyped, "fonts" => { "type" => ::Array["object" | "null"] }, "print_media" => { "type" => ::Array["object" | "null"] }, "paged_js" => { "type" => ::Array["object" | "null"] }, "screenshot" => { "type" => ::Array["string" | "null"], "description" => "path written, when --screenshot was given" }, "pdf" => { "type" => ::Array["object" | "null"], "description" => "present when --pdf was given", "properties" => { "path" => { "type" => "string" }, "pages" => { "type" => ::Array["integer" | "null"] }, "not_blank" => { "type" => ::Array["boolean" | "null"] } } }, "warnings" => untyped, "error" => untyped } }
22
+
23
+ RUN_STEP: { "type" => "object", "required" => ::Array["index" | "type" | "ok"], "properties" => { "index" => { "type" => "integer" }, "type" => { "type" => "string" }, "ok" => { "type" => "boolean" }, "duration_ms" => { "type" => "integer" }, "details" => { "type" => "object" } } }
24
+
25
+ RUN: { "$schema" => "https://json-schema.org/draft/2020-12/schema", "title" => "bidi2pdf run result", "description" => "--json output of `bidi2pdf run recipe.yml`, and the final \"result\" event of its --json-stream", "type" => "object", "required" => ::Array["schema_version" | "ok" | "command" | "recipe" | "actions" | "assertions" | "warnings"], "properties" => { "schema_version" => { "const" => 1 }, "ok" => { "type" => "boolean" }, "command" => { "const" => "run" }, "recipe" => { "type" => "string", "description" => "the recipe file path given on the command line" }, "actions" => { "type" => "array", "items" => untyped }, "assertions" => { "type" => "array", "items" => untyped }, "assigned" => { "type" => "object", "description" => "values captured by evaluate ... assign:, keyed by name" }, "output" => { "type" => ::Array["object" | "null"], "description" => "null when the run failed", "properties" => { "pdf" => { "type" => ::Array["string" | "null"] }, "manifest" => { "type" => ::Array["string" | "null"] }, "screenshot" => { "type" => ::Array["string" | "null"] }, "bytes" => { "type" => ::Array["integer" | "null"] }, "sha256" => { "type" => ::Array["string" | "null"] }, "pages" => { "type" => ::Array["integer" | "null"] } } }, "duration_ms" => { "type" => ::Array["integer" | "null"] }, "warnings" => untyped, "error" => untyped } }
26
+
27
+ MANIFEST: { "$schema" => "https://json-schema.org/draft/2020-12/schema", "title" => "Bidi2pdf render manifest", "description" => "--manifest FILE output", "type" => "object", "required" => ::Array["schema_version" | "bidi2pdf_version" | "created_at" | "input" | "output" | "navigation" | "render"], "properties" => { "schema_version" => { "const" => 1 }, "bidi2pdf_version" => { "type" => "string" }, "created_at" => { "type" => "string", "format" => "date-time" }, "input" => { "type" => "object", "required" => ::Array["type"], "properties" => { "type" => { "type" => "string", "enum" => ::Array["url" | "html_file" | "stdin"] }, "url" => { "type" => ::Array["string" | "null"] } } }, "output" => { "type" => "object", "properties" => { "path" => { "type" => ::Array["string" | "null"] }, "pages" => { "type" => ::Array["integer" | "null"] }, "bytes" => { "type" => ::Array["integer" | "null"] }, "sha256" => { "type" => ::Array["string" | "null"] } } }, "browser" => { "type" => "object" }, "navigation" => untyped, "render" => { "type" => "object", "properties" => { "duration_ms" => { "type" => ::Array["integer" | "null"] } } }, "console" => untyped, "network_failures" => untyped, "warnings" => untyped, "headers" => { "type" => "object", "description" => "present only when custom headers were given; secret-bearing values are redacted" } } }
28
+
29
+ EVENT: { "$schema" => "https://json-schema.org/draft/2020-12/schema", "title" => "Bidi2pdf --json-stream event", "description" => "one JSON object per stderr line while --json-stream is active. In structured mode (--json/--output -), the last line is always {event: \"result\", result: <the matching command's own schema - see \"result\"'s own oneOf>}. In human mode (--json-stream given alone), only progress events are emitted - there is no synthetic final \"result\" event, since there is no structured result being built; the human-readable log is the completion/failure signal.", "type" => "object", "required" => ::Array["schema_version" | "t_ms" | "event"], "properties" => { "schema_version" => { "const" => 1 }, "t_ms" => { "type" => "integer", "description" => "milliseconds since the render started" }, "event" => { "type" => "string", "enum" => ::Array["navigate" | "page_loaded" | "network_idle" | "console" | "print" | "screenshot" | "result"] }, "url" => { "type" => "string", "description" => "present on a navigate event" }, "level" => { "type" => "string", "description" => "present on a console event" }, "text" => { "type" => "string", "description" => "present on a console event" }, "result" => { "description" => "present only on the final \"result\" event (structured mode only); shape depends on which command produced this stream - each branch's own \"command\" value disambiguates", "oneOf" => ::Array[untyped] } } }
30
+
31
+ RECIPE_SOURCE: { "description" => "exactly one of url, file, stdin", "oneOf" => ::Array[{ "type" => "object", "required" => ::Array["url"], "properties" => { "url" => { "type" => "string" } }, "additionalProperties" => false } | { "type" => "object", "required" => ::Array["file"], "properties" => { "file" => { "type" => "string" } }, "additionalProperties" => false } | { "type" => "object", "required" => ::Array["stdin"], "properties" => { "stdin" => { "const" => true } }, "additionalProperties" => false }] }
32
+
33
+ RECIPE_ACTIONS: { "description" => "one of the 7 known actions - a thin dispatch over BrowserTab's own methods, see README's Agent and Automation Usage section", "oneOf" => ::Array[{ "type" => "object", "required" => ::Array["wait_for"], "additionalProperties" => false, "properties" => { "wait_for" => { "type" => "object", "description" => "exactly one of selector, paged_js, script; timeout is always optional", "oneOf" => ::Array[{ "type" => "object", "required" => ::Array["selector"], "additionalProperties" => false, "properties" => { "selector" => { "type" => "string" }, "timeout" => { "type" => "number" } } } | { "type" => "object", "required" => ::Array["paged_js"], "additionalProperties" => false, "properties" => { "paged_js" => { "type" => "boolean" }, "timeout" => { "type" => "number" } } } | { "type" => "object", "required" => ::Array["script"], "additionalProperties" => false, "properties" => { "script" => { "type" => "string" }, "timeout" => { "type" => "number" } } }] } } } | { "type" => "object", "required" => ::Array["click"], "additionalProperties" => false, "properties" => { "click" => { "type" => "object", "required" => ::Array["selector"], "additionalProperties" => false, "properties" => { "selector" => { "type" => "string" } } } } } | { "type" => "object", "required" => ::Array["evaluate"], "additionalProperties" => false, "properties" => { "evaluate" => { "type" => "object", "required" => ::Array["script"], "additionalProperties" => false, "properties" => { "script" => { "type" => "string" }, "assign" => { "type" => "string" } } } } } | { "type" => "object", "required" => ::Array["inject_script"], "additionalProperties" => false, "properties" => { "inject_script" => { "type" => "object", "additionalProperties" => false, "properties" => { "url" => { "type" => "string" }, "content" => { "type" => "string" }, "id" => { "type" => "string" } } } } } | { "type" => "object", "required" => ::Array["inject_style"], "additionalProperties" => false, "properties" => { "inject_style" => { "type" => "object", "additionalProperties" => false, "properties" => { "url" => { "type" => "string" }, "content" => { "type" => "string" }, "id" => { "type" => "string" } } } } } | { "type" => "object", "required" => ::Array["set_viewport"], "additionalProperties" => false, "properties" => { "set_viewport" => { "type" => "object", "required" => ::Array["width" | "height"], "additionalProperties" => false, "properties" => { "width" => { "type" => "number" }, "height" => { "type" => "number" }, "device_pixel_ratio" => { "type" => "number" } } } } } | { "type" => "object", "required" => ::Array["wait_network_idle"], "additionalProperties" => false, "properties" => { "wait_network_idle" => { "type" => "object", "additionalProperties" => false, "properties" => { "timeout" => { "type" => "number" } } } } }] }
34
+
35
+ RECIPE_ASSERT: { "description" => "one of the 8 known assertions. The presence-only ones (no_console_errors, no_network_failures, fonts_loaded, pdf_not_blank) never have their value read at run time - Recipe::Runner only checks the key is there - so they must be written as `true`; `false` or any other value is rejected as misleading rather than silently ignored. The last 3 assertions also require the pdf-reader gem at --validate time - a runtime fact this schema cannot express, see the top-level description", "oneOf" => ::Array[{ "type" => "object", "required" => ::Array["selector_exists"], "additionalProperties" => false, "properties" => { "selector_exists" => { "type" => "object", "required" => ::Array["selector"], "additionalProperties" => false, "properties" => { "selector" => { "type" => "string" } } } } } | { "type" => "object", "required" => ::Array["text_present"], "additionalProperties" => false, "properties" => { "text_present" => { "type" => "object", "required" => ::Array["text"], "additionalProperties" => false, "properties" => { "text" => { "type" => "string" } } } } } | { "type" => "object", "required" => ::Array["no_console_errors"], "additionalProperties" => false, "properties" => { "no_console_errors" => { "const" => true } } } | { "type" => "object", "required" => ::Array["no_network_failures"], "additionalProperties" => false, "properties" => { "no_network_failures" => { "const" => true } } } | { "type" => "object", "required" => ::Array["fonts_loaded"], "additionalProperties" => false, "properties" => { "fonts_loaded" => { "const" => true } } } | { "type" => "object", "required" => ::Array["page_count"], "additionalProperties" => false, "properties" => { "page_count" => { "oneOf" => ::Array[{ "type" => "integer" } | { "type" => "object", "properties" => { "min" => { "type" => "integer" }, "max" => { "type" => "integer" } }, "additionalProperties" => false }] } } } | { "type" => "object", "required" => ::Array["pdf_text_present"], "additionalProperties" => false, "properties" => { "pdf_text_present" => { "type" => "object", "required" => ::Array["text"], "additionalProperties" => false, "properties" => { "text" => { "type" => "string" } } } } } | { "type" => "object", "required" => ::Array["pdf_not_blank"], "additionalProperties" => false, "properties" => { "pdf_not_blank" => { "const" => true } } }] }
36
+
37
+ RECIPE_PRINT: { "type" => "object", "description" => "the same options `bidi2pdf render`'s own print flags map to; validated by Bidi::Commands::PrintParametersValidator", "properties" => { "background" => { "type" => "boolean" }, "shrinkToFit" => { "type" => "boolean" }, "orientation" => { "type" => "string", "enum" => ::Array["portrait" | "landscape"] }, "scale" => { "type" => "number", "minimum" => ::Float, "maximum" => ::Float }, "pageRanges" => { "type" => "array", "items" => { "type" => ::Array["integer" | "string"] } }, "margin" => { "type" => "object", "properties" => { "top" => { "type" => "number", "minimum" => 0 }, "bottom" => { "type" => "number", "minimum" => 0 }, "left" => { "type" => "number", "minimum" => 0 }, "right" => { "type" => "number", "minimum" => 0 } } }, "page" => { "type" => "object", "properties" => { "format" => { "type" => "string" }, "width" => { "type" => "number", "minimum" => ::Float }, "height" => { "type" => "number", "minimum" => ::Float } } } } }
38
+
39
+ RECIPE: { "$schema" => "https://json-schema.org/draft/2020-12/schema", "title" => "Bidi2pdf recipe", "description" => "input to `bidi2pdf run recipe.yml` (YAML or JSON). This schema is meant to be sufficient on its own to generate a recipe `--validate` will accept, with one exception JSON Schema cannot express: a page_count/pdf_text_present/pdf_not_blank assertion also requires the pdf-reader gem to be installed at validation time (a runtime environment fact, not a document-shape one) - `--validate` still catches a violation, with error code PDF_INSPECTION_UNAVAILABLE.", "type" => "object", "required" => ::Array["version" | "source" | "output"], "additionalProperties" => false, "properties" => { "version" => { "const" => 1 }, "source" => untyped, "browser" => { "type" => "object", "properties" => { "headless" => { "type" => "boolean" } } }, "headers" => { "type" => "object" }, "cookies" => { "type" => "object" }, "auth" => { "type" => "object", "properties" => { "username" => { "type" => "string" }, "password" => { "type" => "string" } } }, "actions" => { "type" => "array", "items" => untyped }, "assert" => { "type" => "array", "items" => untyped }, "print" => untyped, "output" => { "type" => "object", "description" => "at least one of pdf, manifest, screenshot", "anyOf" => ::Array[{ "required" => ::Array["pdf"] } | { "required" => ::Array["manifest"] } | { "required" => ::Array["screenshot"] }], "properties" => { "pdf" => { "type" => "string" }, "manifest" => { "type" => "string" }, "screenshot" => { "type" => "string" } } } } }
40
+
41
+ ALL: { "render" => untyped, "diagnose" => untyped, "run" => untyped, "manifest" => untyped, "recipe" => untyped, "event" => untyped }
42
+
43
+ def self.for: (untyped kind) -> untyped
44
+ end
45
+ end
@@ -67,6 +67,17 @@ module Bidi2pdf
67
67
 
68
68
  def run: () -> untyped
69
69
 
70
+ # Like #run, but stops after navigation (and the configured waits) instead of printing, and
71
+ # leaves the tab open for the caller to inspect - used by `bidi2pdf diagnose`, which has no PDF
72
+ # to produce. The caller is responsible for #close_all once done.
73
+ #
74
+ # @return [Bidi2pdf::Bidi::BrowserTab] the navigated tab.
75
+ def run_diagnose: () -> untyped
76
+
77
+ # Closes what #run_diagnose opened. #run's own run_flow already does this itself (in its
78
+ # ensure), so this is only ever needed after #run_diagnose.
79
+ def close_all: () -> untyped
80
+
70
81
  private
71
82
 
72
83
  def setup_browser: () -> untyped
@@ -77,6 +88,12 @@ module Bidi2pdf
77
88
 
78
89
  def add_basic_auth: () -> (nil | untyped)
79
90
 
91
+ # Navigate + wait only, no log_network_traffic/print side effects - #run_flow above
92
+ # intentionally keeps its own inline sequence (where log_network_traffic sits between the two
93
+ # waits) untouched rather than sharing this, since that render-only file-writing side effect
94
+ # has no place in a diagnose flow.
95
+ def navigate_and_wait: () -> untyped
96
+
80
97
  # rubocop: disable Metrics/AbcSize
81
98
  def run_flow: () -> untyped
82
99
 
@@ -1,3 +1,3 @@
1
1
  module Bidi2pdf
2
- VERSION: "0.1.15"
2
+ VERSION: "0.1.16"
3
3
  end
data/sig/bidi2pdf.rbs CHANGED
@@ -11,13 +11,30 @@ module Bidi2pdf
11
11
 
12
12
  PAPER_FORMATS_CM: { letter: { width: ::Float, height: ::Float }, legal: { width: ::Float, height: ::Float }, tabloid: { width: ::Float, height: ::Float }, ledger: { width: ::Float, height: ::Float }, a0: { width: ::Float, height: ::Float }, a1: { width: ::Float, height: ::Float }, a2: { width: ::Float, height: ::Float }, a3: { width: ::Float, height: ::Float }, a4: { width: ::Float, height: ::Float }, a5: { width: ::Float, height: ::Float }, a6: { width: ::Float, height: ::Float } }
13
13
 
14
+ # Base class for every error this gem raises. Carries the machine-readable surface used by
15
+ # --json/--json-stream/manifests/recipe results (see Bidi2pdf::ErrorCodes): #retryable? and
16
+ # #hint are overridden per subclass below where a useful default exists; #details is set at
17
+ # raise time for structured, serializable context (e.g. which selector, which assertion).
14
18
  class Error < StandardError
19
+ @details: untyped
20
+
21
+ attr_reader details: untyped
22
+
23
+ def initialize: (?untyped? message, ?details: ::Hash[untyped, untyped]) -> void
24
+
25
+ def retryable?: () -> false
26
+
27
+ def hint: () -> nil
15
28
  end
16
29
 
17
30
  class SessionNotStartedError < Error
31
+ def retryable?: () -> true
32
+
33
+ def hint: () -> "Check Chrome/chromedriver are installed and reachable, or that --remote-browser-url points at a running instance"
18
34
  end
19
35
 
20
36
  class WebsocketError < Error
37
+ def retryable?: () -> true
21
38
  end
22
39
 
23
40
  class ClientError < WebsocketError
@@ -39,6 +56,9 @@ module Bidi2pdf
39
56
  end
40
57
 
41
58
  class CmdTimeoutError < ClientError
59
+ def retryable?: () -> true
60
+
61
+ def hint: () -> "Raise --default-timeout"
42
62
  end
43
63
 
44
64
  class PrintError < Error
@@ -70,15 +90,61 @@ module Bidi2pdf
70
90
  attr_reader url: untyped
71
91
 
72
92
  def initialize: (untyped url, ?untyped? message) -> void
93
+
94
+ def hint: () -> "Pass --auth user:pass, or check the credentials are still valid"
73
95
  end
74
96
 
75
97
  class NavigationTimeoutError < NavigationError
98
+ def retryable?: () -> true
99
+
100
+ def hint: () -> "Raise --default-timeout, or add --wait-network-idle if the page loads data after the load event"
76
101
  end
77
102
 
78
103
  class NavigationNotFoundError < NavigationError
104
+ def hint: () -> "Check the URL is correct"
79
105
  end
80
106
 
81
107
  class NavigationDNSError < NavigationError
108
+ @url: untyped
109
+
110
+ attr_reader url: untyped
111
+
112
+ def initialize: (?untyped? url, ?untyped? message) -> void
113
+
114
+ def retryable?: () -> true
115
+
116
+ def hint: () -> "Check the hostname is correct and reachable from this machine/container"
117
+ end
118
+
119
+ class MissingInputError < Error
120
+ end
121
+
122
+ class MultipleInputSourcesError < Error
123
+ end
124
+
125
+ class EmptyInputError < Error
126
+ end
127
+
128
+ class InvalidConfigError < Error
129
+ end
130
+
131
+ class InvalidPrintOptionError < Error
132
+ end
133
+
134
+ class InvalidRecipeError < Error
135
+ end
136
+
137
+ class SelectorNotFoundError < Error
138
+ end
139
+
140
+ class PageNotAsExpectedError < Error
141
+ end
142
+
143
+ class OutputWriteError < Error
144
+ end
145
+
146
+ class PdfInspectionUnavailableError < Error
147
+ def hint: () -> "Install the pdf-reader gem, or drop assertions/fields that need it"
82
148
  end
83
149
 
84
150
  attr_accessor self.default_timeout: untyped