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
data/lib/bidi2pdf.rb CHANGED
@@ -9,10 +9,11 @@ require_relative "bidi2pdf/bidi/session"
9
9
  require_relative "bidi2pdf/dsl"
10
10
  require_relative "bidi2pdf/notifications"
11
11
  require_relative "bidi2pdf/notifications/logging_subscriber"
12
+ require_relative "bidi2pdf/notifications/json_subscriber"
12
13
  require_relative "bidi2pdf/session_warmer"
13
14
  require_relative "bidi2pdf/verbose_logger"
14
15
 
15
- module Bidi2pdf
16
+ module Bidi2pdf # rubocop:disable Metrics/ModuleLength
16
17
  PAPER_FORMATS_CM = {
17
18
  letter: { width: 21.59, height: 27.94 },
18
19
  legal: { width: 21.59, height: 35.56 },
@@ -27,11 +28,32 @@ module Bidi2pdf
27
28
  a6: { width: 10.5, height: 14.8 }
28
29
  }.freeze
29
30
 
30
- class Error < StandardError; end
31
+ # Base class for every error this gem raises. Carries the machine-readable surface used by
32
+ # --json/--json-stream/manifests/recipe results (see Bidi2pdf::ErrorCodes): #retryable? and
33
+ # #hint are overridden per subclass below where a useful default exists; #details is set at
34
+ # raise time for structured, serializable context (e.g. which selector, which assertion).
35
+ class Error < StandardError
36
+ attr_reader :details
31
37
 
32
- class SessionNotStartedError < Error; end
38
+ def initialize(message = nil, details: {})
39
+ @details = details
40
+ super(message)
41
+ end
42
+
43
+ def retryable? = false
33
44
 
34
- class WebsocketError < Error; end
45
+ def hint = nil
46
+ end
47
+
48
+ class SessionNotStartedError < Error
49
+ def retryable? = true
50
+
51
+ def hint = "Check Chrome/chromedriver are installed and reachable, or that --remote-browser-url points at a running instance"
52
+ end
53
+
54
+ class WebsocketError < Error
55
+ def retryable? = true
56
+ end
35
57
 
36
58
  class ClientError < WebsocketError; end
37
59
 
@@ -48,7 +70,11 @@ module Bidi2pdf
48
70
 
49
71
  class CmdResponseNotStoredError < ClientError; end
50
72
 
51
- class CmdTimeoutError < ClientError; end
73
+ class CmdTimeoutError < ClientError
74
+ def retryable? = true
75
+
76
+ def hint = "Raise --default-timeout"
77
+ end
52
78
 
53
79
  class PrintError < Error; end
54
80
 
@@ -77,13 +103,69 @@ module Bidi2pdf
77
103
  @url = url
78
104
  super("Navigation to #{url} failed due to authentication error. #{message}")
79
105
  end
106
+
107
+ def hint = "Pass --auth user:pass, or check the credentials are still valid"
108
+ end
109
+
110
+ class NavigationTimeoutError < NavigationError
111
+ def retryable? = true
112
+
113
+ def hint = "Raise --default-timeout, or add --wait-network-idle if the page loads data after the load event"
114
+ end
115
+
116
+ class NavigationNotFoundError < NavigationError
117
+ def hint = "Check the URL is correct"
118
+ end
119
+
120
+ class NavigationDNSError < NavigationError
121
+ attr_reader :url
122
+
123
+ def initialize(url = nil, message = nil)
124
+ @url = url
125
+ super(url ? "Navigation to #{url} failed: DNS resolution error. #{message}" : message)
126
+ end
127
+
128
+ def retryable? = true
129
+
130
+ def hint = "Check the hostname is correct and reachable from this machine/container"
80
131
  end
81
132
 
82
- class NavigationTimeoutError < NavigationError; end
133
+ # --- Errors introduced for the CLI/recipe surface.
134
+ # Same style as the errors above: a thin subclass per failure mode, message text carried at the
135
+ # raise site so it stays exactly as specific as the situation warrants.
136
+
137
+ class MissingInputError < Error; end
138
+
139
+ class MultipleInputSourcesError < Error; end
83
140
 
84
- class NavigationNotFoundError < NavigationError; end
141
+ class EmptyInputError < Error; end
142
+
143
+ class InvalidConfigError < Error; end
144
+
145
+ class InvalidPrintOptionError < Error; end
146
+
147
+ class InvalidRecipeError < Error; end
148
+
149
+ class SelectorNotFoundError < Error; end
150
+
151
+ class PageNotAsExpectedError < Error; end
152
+
153
+ class OutputWriteError < Error; end
154
+
155
+ class PdfInspectionUnavailableError < Error
156
+ def hint = "Install the pdf-reader gem, or drop assertions/fields that need it"
157
+ end
85
158
 
86
- class NavigationDNSError < NavigationError; end
159
+ # Loaded here, after the error classes above, since these reference them at load time.
160
+ require_relative "bidi2pdf/error_codes"
161
+ require_relative "bidi2pdf/exit_codes"
162
+ require_relative "bidi2pdf/result"
163
+ require_relative "bidi2pdf/pdf_inspection"
164
+ require_relative "bidi2pdf/result_collector"
165
+ require_relative "bidi2pdf/manifest"
166
+ require_relative "bidi2pdf/schema"
167
+ require_relative "bidi2pdf/diagnose"
168
+ require_relative "bidi2pdf/recipe"
87
169
 
88
170
  # Global configuration for Bidi2pdf
89
171
 
@@ -0,0 +1,19 @@
1
+ module Bidi2pdf
2
+ class CLI < Thor
3
+ # Shared machinery for --json/--json-stream/--output - across render/diagnose/run: reserving
4
+ # stdout for exactly one machine-readable payload, and mapping a Result to a process exit
5
+ # status.
6
+ module JsonOutput
7
+ private
8
+
9
+ # Bidi2pdf.logger (and friends) default to $stdout (see lib/bidi2pdf.rb), so a
10
+ # --json/--output - render has to redirect them for its duration or every log line would
11
+ # land in the same stream as the JSON document / PDF bytes it is trying to keep pure.
12
+ # Logger#reopen swaps the destination in place, so nothing else holding a reference to
13
+ # these loggers needs to know.
14
+ def reserve_stdout_for_machine_output: () { () -> untyped } -> untyped
15
+
16
+ def exit_for_result: (untyped result) -> untyped
17
+ end
18
+ end
19
+ end
data/sig/bidi2pdf/cli.rbs CHANGED
@@ -1,21 +1,131 @@
1
1
  module Bidi2pdf
2
2
  # rubocop:disable Metrics/AbcSize
3
3
  class CLI < Thor
4
+ @run_launcher: untyped
5
+
6
+ @render_input_path: untyped
7
+
8
+ @stdin_content: untyped
9
+
10
+ @stdin_tempfile: untyped
11
+
4
12
  @launcher: untyped
5
13
 
14
+ include JsonOutput
15
+
6
16
  def self.exit_on_failure?: () -> true
7
17
 
8
- def render: () -> untyped
18
+ def render: () -> (nil | untyped)
9
19
 
10
20
  def version: () -> untyped
11
21
 
22
+ def schema: (untyped kind) -> untyped
23
+
24
+ def diagnose: () -> untyped
25
+
26
+ # Named run_recipe, not run: Thor::Base reserves "run" as a method name (Thor::Base::
27
+ # ClassMethods#is_thor_reserved_word?) and refuses to define a command with that name. `map`
28
+ # below is what makes `bidi2pdf run recipe.yml` dispatch here.
29
+ def run_recipe: (untyped recipe_path) -> untyped
30
+
12
31
  def template: () -> untyped
13
32
 
14
33
  private
15
34
 
35
+ # rubocop:disable-next Metrics/CyclomaticComplexity
36
+ def perform_human_render: () -> untyped
37
+
38
+ def perform_structured_render: () -> untyped
39
+
40
+ def emit_structured_render_output: (untyped result, untyped collector) -> untyped
41
+
42
+ def perform_human_diagnose: () -> untyped
43
+
44
+ def perform_structured_diagnose: () -> untyped
45
+
46
+ def diagnose_payload: (untyped result, untyped diagnostics) -> { schema_version: 1, ok: untyped, command: "diagnose", page: untyped, console: untyped, network_failures: untyped, fonts: untyped, print_media: untyped, paged_js: untyped, screenshot: untyped, pdf: untyped, warnings: untyped, error: untyped }
47
+
48
+ def capture_diagnose_screenshot: (untyped tab) -> (nil | untyped)
49
+
50
+ def capture_diagnose_pdf: (untyped tab) -> (nil | { path: untyped, pages: untyped, not_blank: untyped })
51
+
52
+ # rubocop:disable-next Metrics/CyclomaticComplexity
53
+ def perform_human_run: (untyped recipe_path) -> untyped
54
+
55
+ # rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
56
+ def perform_structured_run: (untyped recipe_path) -> untyped
57
+
58
+ def load_and_validate_recipe: (untyped recipe_path) -> untyped
59
+
60
+ def recipe_requested_url: (untyped recipe) -> untyped
61
+
62
+ def with_recipe_tab: (untyped recipe) { (untyped) -> untyped } -> untyped
63
+
64
+ # A failing action/assertion raises Recipe::Runner::StepFailure (its own entries plus the real
65
+ # Bidi2pdf::Error as #cause). The block always receives the entries run so far - whether every
66
+ # step succeeded or the run stopped partway - before the cause is re-raised so
67
+ # ResultCollector's ordinary error handling takes over; without the block, a failure's partial
68
+ # entries would otherwise be lost along with the exception that discarded them.
69
+ def run_recipe_steps: (untyped runner, untyped method_name) { (untyped) -> untyped } -> untyped
70
+
71
+ def launcher_for_recipe: (untyped recipe) -> untyped
72
+
73
+ def recipe_input_path: (untyped recipe) -> (nil | untyped)
74
+
75
+ def print_for_recipe: (untyped tab, untyped recipe) -> untyped
76
+
77
+ def symbolize_print_options: (untyped opts) -> untyped
78
+
79
+ def write_recipe_screenshot: (untyped tab, untyped recipe) -> (nil | untyped)
80
+
81
+ def write_recipe_manifest: (untyped recipe, untyped result) -> untyped
82
+
83
+ def recipe_input_type: (untyped recipe) -> ("url" | "html_file" | "stdin")
84
+
85
+ def run_payload: (untyped recipe_path, untyped recipe, untyped result, untyped action_entries, untyped assertion_entries, untyped state) -> { schema_version: 1, ok: untyped, command: "run", recipe: untyped, actions: untyped, assertions: untyped, assigned: untyped, output: untyped, duration_ms: untyped, warnings: untyped, error: untyped }
86
+
87
+ def recipe_output_summary: (untyped recipe, untyped result) -> untyped
88
+
89
+ # --json and --output - both need exclusive use of stdout - rejected before anything else
90
+ # runs, so neither stream is touched. The rejection
91
+ # itself is reported on stderr, since stdout's meaning is exactly what is in dispute.
92
+ def reject_conflicting_output_streams!: () -> untyped
93
+
94
+ def json_mode?: () -> untyped
95
+
96
+ def json_stream?: () -> untyped
97
+
98
+ def stdin_mode?: () -> untyped
99
+
100
+ def stdout_output?: () -> untyped
101
+
102
+ def render_output_label: () -> ("-" | untyped)
103
+
104
+ def render_output_path: () -> (nil | untyped)
105
+
106
+ def requested_source_description: () -> untyped
107
+
108
+ def write_manifest: (untyped result) -> untyped
109
+
110
+ def input_type: () -> ("url" | "html_file" | "stdin")
111
+
16
112
  def load_config: () -> (nil | untyped)
17
113
 
18
- def validate_required_options!: () -> (untyped | untyped | nil)
114
+ def input_sources_provided: () -> untyped
115
+
116
+ def validate_required_options!: () -> untyped
117
+
118
+ def render_input_path: () -> untyped
119
+
120
+ def stdin_content: () -> untyped
121
+
122
+ def write_stdin_tempfile: () -> untyped
123
+
124
+ def cleanup_stdin_tempfile: () -> (nil | untyped)
125
+
126
+ def version_info: () -> { schema_version: 1, ok: true, command: "version", bidi2pdf: untyped, ruby: untyped, pdf_reader: untyped }
127
+
128
+ def pdf_reader_version: () -> (nil | untyped)
19
129
 
20
130
  def validate_print_options: (untyped opts) -> untyped
21
131
 
@@ -0,0 +1,30 @@
1
+ module Bidi2pdf
2
+ # Collects the render-focused diagnostics behind `bidi2pdf diagnose`: why does the PDF not look
3
+ # like the page, not what the page's content is. Everything here reuses
4
+ # BrowserTab#execute_script - no DOM dump, no headings/links/forms extraction; console and
5
+ # network data come from the caller's own
6
+ # ResultCollector, the same source `render` uses, not from here.
7
+ #
8
+ # Each script returns (or resolves to) a JSON string, extracted via #dig("result", "value") -
9
+ # BiDi's script.evaluate already awaits a returned Promise (awaitPromise: true, see
10
+ # Commands::ScriptEvaluate), so document.fonts.ready needs no wrap_in_promise: wrapper.
11
+ class Diagnose
12
+ @tab: untyped
13
+
14
+ PAGE_SCRIPT: "JSON.stringify({\n title: document.title,\n url: window.location.href,\n lang: document.documentElement.lang || null\n})\n"
15
+
16
+ FONTS_SCRIPT: "document.fonts.ready.then(function () {\n var loaded = [];\n var failed = [];\n document.fonts.forEach(function (face) {\n var label = face.family + \" \" + face.weight + \" \" + face.style;\n if (face.status === \"error\") { failed.push(label); } else { loaded.push(label); }\n });\n return JSON.stringify({ status: failed.length > 0 ? \"partial\" : \"loaded\", loaded: loaded, failed: failed });\n})\n"
17
+
18
+ PRINT_MEDIA_SCRIPT: "(function () {\n var printSheets = [];\n var pageRules = [];\n var unreadable = [];\n\n for (var i = 0; i < document.styleSheets.length; i++) {\n var sheet = document.styleSheets[i];\n try {\n var rules = sheet.cssRules || sheet.rules;\n for (var j = 0; j < rules.length; j++) {\n var rule = rules[j];\n if (rule.media && Array.prototype.includes.call(rule.media, \"print\")) {\n printSheets.push(sheet.href || \"(inline)\");\n }\n if (typeof CSSRule !== \"undefined\" && rule.type === CSSRule.PAGE_RULE) {\n pageRules.push(rule.cssText);\n }\n }\n } catch (e) {\n unreadable.push(sheet.href || \"(inline)\");\n }\n }\n\n var fixedOrSticky = [];\n var breakInsideAvoidCount = 0;\n var elements = document.querySelectorAll(\"body *\");\n for (var k = 0; k < elements.length; k++) {\n var el = elements[k];\n var computed = window.getComputedStyle(el);\n if (computed.position === \"fixed\" || computed.position === \"sticky\") {\n var classPart = typeof el.className === \"string\" && el.className.trim() ? \".\" + el.className.trim().split(/\\s+/).join(\".\") : \"\";\n var selector = el.id ? \"#\" + el.id : el.tagName.toLowerCase() + classPart;\n fixedOrSticky.push({ selector: selector, position: computed.position });\n }\n if (computed.breakInside === \"avoid\" || computed.pageBreakInside === \"avoid\") {\n breakInsideAvoidCount++;\n }\n }\n\n return JSON.stringify({\n stylesheets_with_print_rules: Array.from(new Set(printSheets)),\n page_rules: pageRules,\n break_inside_avoid_count: breakInsideAvoidCount,\n fixed_or_sticky_elements: fixedOrSticky,\n unreadable_stylesheets: Array.from(new Set(unreadable))\n });\n})()\n"
19
+
20
+ PAGED_JS_SCRIPT: "(function () {\n var detected = !!(window.Paged || window.PagedPolyfill);\n var pages = document.querySelectorAll(\".pagedjs_page\").length;\n return JSON.stringify({ detected: detected, ready: pages > 0, pages: pages });\n})()\n"
21
+
22
+ def initialize: (tab: untyped) -> void
23
+
24
+ def call: () -> { page: untyped, fonts: untyped, print_media: untyped, paged_js: untyped }
25
+
26
+ private
27
+
28
+ def collect: (untyped script) -> untyped
29
+ end
30
+ end
@@ -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
@@ -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