bidi2pdf 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +20 -0
  3. data/CHANGELOG.md +69 -2
  4. data/README.md +258 -10
  5. data/docker/Dockerfile +4 -0
  6. data/docker/Dockerfile.slim +5 -1
  7. data/lib/bidi2pdf/bidi/browser_tab.rb +45 -4
  8. data/lib/bidi2pdf/bidi/buffered_web_socket_client.rb +178 -0
  9. data/lib/bidi2pdf/bidi/client.rb +12 -5
  10. data/lib/bidi2pdf/bidi/commands/base.rb +2 -0
  11. data/lib/bidi2pdf/bidi/network_event.rb +12 -3
  12. data/lib/bidi2pdf/bidi/network_events.rb +9 -1
  13. data/lib/bidi2pdf/bidi/session.rb +1 -1
  14. data/lib/bidi2pdf/chromedriver_manager.rb +10 -6
  15. data/lib/bidi2pdf/cli/json_output.rb +30 -0
  16. data/lib/bidi2pdf/cli.rb +559 -17
  17. data/lib/bidi2pdf/diagnose.rb +119 -0
  18. data/lib/bidi2pdf/error_codes.rb +59 -0
  19. data/lib/bidi2pdf/exit_codes.rb +44 -0
  20. data/lib/bidi2pdf/launcher.rb +23 -0
  21. data/lib/bidi2pdf/manifest.rb +65 -0
  22. data/lib/bidi2pdf/notifications/json_subscriber.rb +78 -0
  23. data/lib/bidi2pdf/notifications/logging_subscriber.rb +2 -0
  24. data/lib/bidi2pdf/pdf_inspection.rb +62 -0
  25. data/lib/bidi2pdf/recipe/loader.rb +44 -0
  26. data/lib/bidi2pdf/recipe/runner.rb +229 -0
  27. data/lib/bidi2pdf/recipe/schema_shape.rb +228 -0
  28. data/lib/bidi2pdf/recipe/validator.rb +138 -0
  29. data/lib/bidi2pdf/recipe.rb +83 -0
  30. data/lib/bidi2pdf/result.rb +67 -0
  31. data/lib/bidi2pdf/result_collector.rb +150 -0
  32. data/lib/bidi2pdf/schema.rb +382 -0
  33. data/lib/bidi2pdf/session_runner.rb +42 -0
  34. data/lib/bidi2pdf/session_warmer.rb +377 -0
  35. data/lib/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rb +4 -2
  36. data/lib/bidi2pdf/version.rb +1 -1
  37. data/lib/bidi2pdf.rb +114 -9
  38. data/sig/bidi2pdf/bidi/browser_tab.rbs +17 -0
  39. data/sig/bidi2pdf/bidi/buffered_web_socket_client.rbs +81 -0
  40. data/sig/bidi2pdf/bidi/client.rbs +10 -2
  41. data/sig/bidi2pdf/bidi/network_event.rbs +11 -1
  42. data/sig/bidi2pdf/bidi/session.rbs +1 -1
  43. data/sig/bidi2pdf/chromedriver_manager.rbs +4 -0
  44. data/sig/bidi2pdf/cli/json_output.rbs +19 -0
  45. data/sig/bidi2pdf/cli.rbs +112 -2
  46. data/sig/bidi2pdf/diagnose.rbs +30 -0
  47. data/sig/bidi2pdf/error_codes.rbs +21 -0
  48. data/sig/bidi2pdf/exit_codes.rbs +21 -0
  49. data/sig/bidi2pdf/launcher.rbs +9 -0
  50. data/sig/bidi2pdf/manifest.rbs +38 -0
  51. data/sig/bidi2pdf/notifications/json_subscriber.rbs +45 -0
  52. data/sig/bidi2pdf/pdf_inspection.rbs +33 -0
  53. data/sig/bidi2pdf/recipe/loader.rbs +20 -0
  54. data/sig/bidi2pdf/recipe/runner.rbs +92 -0
  55. data/sig/bidi2pdf/recipe/schema_shape.rbs +85 -0
  56. data/sig/bidi2pdf/recipe/validator.rbs +54 -0
  57. data/sig/bidi2pdf/recipe.rbs +72 -0
  58. data/sig/bidi2pdf/result.rbs +71 -0
  59. data/sig/bidi2pdf/result_collector.rbs +106 -0
  60. data/sig/bidi2pdf/schema.rbs +45 -0
  61. data/sig/bidi2pdf/session_runner.rbs +17 -0
  62. data/sig/bidi2pdf/session_warmer.rbs +221 -0
  63. data/sig/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rbs +1 -1
  64. data/sig/bidi2pdf/version.rbs +1 -1
  65. data/sig/bidi2pdf.rbs +84 -0
  66. data/tasks/release_credentials_check.rake +97 -0
  67. metadata +39 -4
@@ -40,14 +40,22 @@ module Bidi2pdf
40
40
 
41
41
  # Starts the WebSocket client and establishes a connection.
42
42
  #
43
- # @return [WebSocket::Client::Simple] The WebSocket connection object.
43
+ # @return [BufferedWebSocketClient] The WebSocket connection object.
44
44
  def start: () -> untyped
45
45
 
46
- # Checks if the WebSocket client has started.
46
+ # Checks if the WebSocket client has started. Unlike #open?, this never flips back to false on
47
+ # its own if the connection dies externally - it only reflects whether #start has run.
47
48
  #
48
49
  # @return [Boolean] True if the client has started, false otherwise.
49
50
  def started?: () -> untyped
50
51
 
52
+ # Checks if the underlying WebSocket connection is actually open right now - false before
53
+ # #start, after #close, or if the reader thread noticed the connection died on its own (a
54
+ # real liveness signal, not just an internal "did we ever start" flag).
55
+ #
56
+ # @return [Boolean] True if the underlying socket reports itself open.
57
+ def open?: () -> untyped
58
+
51
59
  # Waits until the WebSocket connection is open.
52
60
  #
53
61
  # @param [Integer] timeout The timeout duration in seconds.
@@ -15,6 +15,8 @@ module Bidi2pdf
15
15
 
16
16
  @http_method: untyped
17
17
 
18
+ @navigation: untyped
19
+
18
20
  @end_timestamp: untyped
19
21
 
20
22
  @bytes_received: untyped
@@ -37,9 +39,17 @@ module Bidi2pdf
37
39
 
38
40
  attr_reader bytes_received: untyped
39
41
 
42
+ attr_reader navigation: untyped
43
+
40
44
  STATE_MAP: { "network.responseStarted" => "started", "network.responseCompleted" => "completed", "network.fetchError" => "error" }
41
45
 
42
- def initialize: (id: untyped, url: untyped, timestamp: untyped, timing: untyped, state: untyped, ?http_status_code: untyped?, ?http_method: untyped?) -> void
46
+ # @param [String, nil] navigation The BiDi navigation ID this request belongs to - present on
47
+ # the main-frame document request, letting a caller correlate a specific
48
+ # browsingContext.navigate call to the network event carrying its actual HTTP status. A
49
+ # sub-resource request (an image, a script, ...) has no real BiDi navigation ID and stays
50
+ # nil here - #id already distinguishes one event from another in logs, so this field is
51
+ # left alone rather than filled with a value the protocol never sent.
52
+ def initialize: (id: untyped, url: untyped, timestamp: untyped, timing: untyped, state: untyped, ?http_status_code: untyped?, ?http_method: untyped?, ?navigation: untyped?) -> void
43
53
 
44
54
  def update_state: (untyped new_state, ?timestamp: untyped?, ?timing: untyped?, ?http_status_code: untyped?, ?bytes_received: untyped?) -> untyped
45
55
 
@@ -37,7 +37,7 @@ module Bidi2pdf
37
37
  SUBSCRIBE_EVENTS: ::Array["script"]
38
38
 
39
39
  # Default Chrome arguments for the session.
40
- DEFAULT_CHROME_ARGS: ::Array["--allow-pre-commit-input" | "--disable-dev-shm-usage" | "--disable-gpu" | "--disable-popup-blocking" | "--disable-hang-monitor" | "--disable-background-networking" | "--disable-background-timer-throttling" | "--disable-client-side-phishing-detection" | "--disable-component-extensions-with-background-pages" | "--disable-crash-reporter" | "--disable-default-apps" | "--disable-infobars" | "--disable-ipc-flooding-protection" | "--disable-prompt-on-repost" | "--disable-renderer-backgrounding" | "--disable-search-engine-choice-screen" | "--disable-sync" | "--enable-automation" | "--export-tagged-pdf" | "--force-color-profile=srgb" | "--generate-pdf-document-outline" | "--metrics-recording-only" | "--no-first-run" | "--password-store=basic" | "--use-mock-keychain" | "--disable-backgrounding-occluded-windows" | "--disable-breakpad" | "--enable-features=PdfOopif" | "--disable-features=Translate,AcceptCHFrame,MediaRouter,OptimizationHints,ProcessPerSiteUpToMainFrameThreshold,IsolateSandboxedIframes" | "--disable-extensions about:blank"]
40
+ DEFAULT_CHROME_ARGS: ::Array["--allow-pre-commit-input" | "--disable-dev-shm-usage" | "--disable-gpu" | "--disable-popup-blocking" | "--disable-hang-monitor" | "--disable-background-networking" | "--disable-background-timer-throttling" | "--disable-client-side-phishing-detection" | "--disable-component-extensions-with-background-pages" | "--disable-crash-reporter" | "--disable-default-apps" | "--disable-infobars" | "--disable-ipc-flooding-protection" | "--disable-prompt-on-repost" | "--disable-renderer-backgrounding" | "--disable-search-engine-choice-screen" | "--disable-sync" | "--enable-automation" | "--export-tagged-pdf" | "--force-color-profile=srgb" | "--generate-pdf-document-outline" | "--metrics-recording-only" | "--no-first-run" | "--password-store=basic" | "--use-mock-keychain" | "--disable-backgrounding-occluded-windows" | "--disable-breakpad" | "--enable-features=PdfOopif" | "--disable-features=Translate,AcceptCHFrame,MediaRouter,OptimizationHints,ProcessPerSiteUpToMainFrameThreshold,IsolateSandboxedIframes" | "--disable-extensions"]
41
41
 
42
42
  # @return [URI] The URI of the session.
43
43
  attr_reader session_uri: untyped
@@ -59,6 +59,10 @@ module Bidi2pdf
59
59
 
60
60
  def build_cmd: () -> untyped
61
61
 
62
+ # Chromedriver's own internal log verbosity is independent from this app's - defaults to
63
+ # mirroring Bidi2pdf.logger.level for backward compatibility, but Bidi2pdf.chromedriver_log_level
64
+ # lets a consumer quiet chromedriver's own (often much more verbose, e.g. full BiDi command/
65
+ # response dumps at its own INFO level) output without changing their app's own log level.
62
66
  def chromedriver_log_level: () -> untyped
63
67
 
64
68
  def user_data_dir_arg: () -> (untyped | ::String)
@@ -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
@@ -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