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
@@ -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
 
@@ -0,0 +1,221 @@
1
+ module Bidi2pdf
2
+ # Keeps a small number of Chrome sessions pre-warmed (chromedriver started, WebSocket connected,
3
+ # browser ready) so a render can skip that startup latency on the request path. Isolation matches
4
+ # today's one-Chrome-per-render model exactly: every checked-out slot is used for exactly one
5
+ # +with_tab+ block and then retired (never returned to the cache) - only a fresh replacement is
6
+ # warmed in its place, off the request path. Checkout never waits for a warm slot: one is used if
7
+ # it is ready, otherwise a slot is created synchronously on the spot, i.e. today's exact behavior
8
+ # for that one render - including its failure mode: if that cold start fails, the error propagates
9
+ # out of +with_tab+ exactly as it would without the warmer.
10
+ #
11
+ # @example Rails initializer
12
+ # Bidi2pdf::SessionWarmer.configure do |c|
13
+ # c.size = 2
14
+ # c.headless = true
15
+ # end
16
+ #
17
+ # @example Per-request usage
18
+ # Bidi2pdf::SessionWarmer.with_tab do |tab|
19
+ # tab.navigate_to(url)
20
+ # tab.print("invoice.pdf")
21
+ # end
22
+ class SessionWarmer
23
+ self.@config: untyped
24
+
25
+ # Cleared first: if the constructor below raises, a stale, already-shut-down instance must
26
+ # not stay registered (it would keep serving cold slots but never warm again).
27
+ self.@instance: untyped
28
+
29
+ @config: untyped
30
+
31
+ @slot_factory: untyped
32
+
33
+ @mutex: untyped
34
+
35
+ @available: untyped
36
+
37
+ @replenish_threads: untyped
38
+
39
+ @warming: untyped
40
+
41
+ @shutdown: untyped
42
+
43
+ @reaper_wakeup: untyped
44
+
45
+ @reaper: untyped
46
+
47
+ # Configuration for the session warmer.
48
+ class Configuration
49
+ @size: untyped
50
+
51
+ @headless: untyped
52
+
53
+ @chrome_args: untyped
54
+
55
+ @remote_browser_url: untyped
56
+
57
+ @slot_factory: untyped
58
+
59
+ @max_idle_age: untyped
60
+
61
+ # @return [Integer] Number of Chrome slots to keep pre-warmed.
62
+ attr_accessor size: untyped
63
+
64
+ # @return [Boolean] Whether to run Chrome in headless mode.
65
+ attr_accessor headless: untyped
66
+
67
+ # @return [Array<String>] Chrome launch arguments.
68
+ attr_accessor chrome_args: untyped
69
+
70
+ # @return [String, nil] A remote chromedriver session URL (e.g. a `remote-chrome` sidecar). When
71
+ # set, a slot connects directly to it instead of spawning a local ChromedriverManager -
72
+ # mirrors Launcher#session's own local/remote branch.
73
+ attr_accessor remote_browser_url: untyped
74
+
75
+ # @return [#call, nil] Optional factory callable that returns a slot hash - injectable for tests.
76
+ # A custom factory owns the cleanup of anything it half-built before raising: the warmer can
77
+ # only retire a slot it was actually handed.
78
+ attr_accessor slot_factory: untyped
79
+
80
+ # @return [Numeric, nil] Seconds a warm slot may sit unused before it is retired and replaced.
81
+ # A warm slot is an open, unauthenticated automation endpoint (chromedriver's port, Chrome's
82
+ # debugging port) for as long as it idles, so that window is bounded by default. A slot older
83
+ # than this is never handed out, and a background reaper recycles idle ones within roughly
84
+ # 1.25x this value even when no render ever comes. +nil+ disables the limit.
85
+ attr_accessor max_idle_age: untyped
86
+
87
+ DEFAULT_MAX_IDLE_AGE: 300
88
+
89
+ def initialize: () -> void
90
+
91
+ # @raise [ArgumentError] if a setting can't be honored - checked once, at construction.
92
+ def validate!: () -> untyped
93
+
94
+ private
95
+
96
+ def validate_size!: () -> (nil | untyped)
97
+
98
+ def validate_max_idle_age!: () -> (nil | untyped)
99
+ end
100
+
101
+ # Configures the warmer and eagerly (re)creates the singleton, warming config.size slots right
102
+ # here - at boot/configuration time, off the request path - instead of lazily on whichever
103
+ # request happens to trigger the first #with_tab (which would otherwise pay for every
104
+ # configured slot, serially, on that one unlucky request).
105
+ def self.configure: () ?{ (untyped) -> untyped } -> untyped
106
+
107
+ # Returns the warmer's configuration, initializing defaults if needed.
108
+ def self.config: () -> untyped
109
+
110
+ # Returns a callable that creates one real, fully-warmed Chrome slot from +config+. If building
111
+ # fails part-way (chromedriver up, but the session or its browser never came ready), whatever
112
+ # already exists is retired before the error propagates - no caller ever gets a reference to a
113
+ # half-built slot, so nobody else could clean it up.
114
+ def self.default_slot_factory: (untyped config) -> untyped
115
+
116
+ # Closes a slot's session and stops its chromedriver (nil for a remote slot, or for a part that
117
+ # was never created). Each step is independent: a failure in one is logged, never raised, and
118
+ # never skips the other.
119
+ def self.retire_slot: (session: untyped, manager: untyped) -> untyped
120
+
121
+ def self.safe_close: (untyped label) { () -> untyped } -> untyped
122
+
123
+ # Returns the shared singleton warmer instance, creating it (and pre-warming it) on first call.
124
+ def self.instance: () -> untyped
125
+
126
+ # Checks out a slot, yields a fresh tab for one render, then retires the slot.
127
+ def self.with_tab: () { (?) -> untyped } -> untyped
128
+
129
+ # Retires every currently-warm spare and resets the singleton.
130
+ def self.shutdown: () -> untyped
131
+
132
+ private
133
+
134
+ # Records each part in +parts+ the moment it exists, so #default_slot_factory's rescue can
135
+ # retire exactly what was created so far. Mirrors Launcher#session's local/remote branch.
136
+ def self.build_slot: (untyped config, untyped parts) -> { session: untyped, browser: untyped, manager: untyped }
137
+
138
+ def self.connect_remote: (untyped config, untyped parts) -> untyped
139
+
140
+ def self.start_local: (untyped config, untyped parts) -> untyped
141
+
142
+ public
143
+
144
+ def initialize: (untyped config, ?slot_factory: untyped?) -> void
145
+
146
+ # Checks out a slot, creates an isolated UserContext/Window/Tab for one render, yields the tab,
147
+ # then unconditionally closes those resources and retires the underlying slot.
148
+ def with_tab: () { (untyped) -> untyped } -> untyped
149
+
150
+ # Retires every currently-warm spare and waits for any in-flight background replenishment to
151
+ # finish (each of those, seeing @shutdown, retires its own result instead of stashing it - see
152
+ # #stash_or_retire). A subsequent #with_tab still works - it just falls back to a synchronous
153
+ # slot, since checkout never depends on a warm one being there.
154
+ def shutdown: () -> untyped
155
+
156
+ private
157
+
158
+ # Fail-fast on purpose (a Chrome that can't start at boot should be loud), but not leaky: if slot
159
+ # N fails, no instance is returned to own slots 1..N-1, so they are retired here first.
160
+ def prewarm: () -> untyped
161
+
162
+ def create_slot: () -> untyped
163
+
164
+ # Session#started? is just an internal flag set once at startup - it never flips back if Chrome,
165
+ # ChromeDriver, or the WebSocket dies externally while a slot sits idle in the cache. The
166
+ # client's own #open? is kept live by the reader thread noticing a real socket error, so
167
+ # checking it too catches that case - cheap (no network round trip), though still a heuristic,
168
+ # not a full liveness guarantee (a stuck-but-not-yet-disconnected socket still reads healthy).
169
+ def healthy?: (untyped slot) -> untyped
170
+
171
+ def now: () -> untyped
172
+
173
+ # Marks the moment a slot entered the cache - idle age counts from here, not from when its
174
+ # Chrome started, since it is the unattended waiting that max_idle_age bounds.
175
+ def stamp: (untyped slot) -> untyped
176
+
177
+ def expired?: (untyped slot) -> untyped
178
+
179
+ # Checkout alone can't bound idle time: with no traffic nothing would ever look at a spare. This
180
+ # thread does, four times per max_idle_age, so an unused slot is recycled within ~1.25x of it.
181
+ # Thread::Queue#pop(timeout:) doubles as an interruptible sleep - #shutdown pushes :stop.
182
+ def reap_loop: () -> untyped
183
+
184
+ def recycle_expired: () -> untyped
185
+
186
+ # A warm hit takes the spare and triggers a background replacement; a miss (empty cache, or a
187
+ # spare that died while idle) falls straight through to a synchronous slot - it never waits, so
188
+ # an under-provisioned warmer is never worse than not having one. A cold start that fails raises,
189
+ # as it would without the warmer.
190
+ #
191
+ # Oldest spare first (shift, not pop), so no slot lingers at the bottom of the cache while newer
192
+ # ones are used; and one past max_idle_age is never handed out, even if the reaper hasn't got to
193
+ # it yet - it is retired like a dead spare and this render cold-starts instead.
194
+ def checkout: () -> untyped
195
+
196
+ def cold_checkout: (untyped dead_slot) -> untyped
197
+
198
+ # Tops the cache up towards config.size, counting warmers already in flight. Deficit-based on
199
+ # purpose, not "replace what this checkout popped": that rule could never recover from a single
200
+ # failed warm (nothing stashed -> every later checkout a miss -> never replenished again), while
201
+ # replenishing unconditionally let @available grow without bound under a burst of misses.
202
+ # available + warming never exceeds config.size, and a failed warm frees its reservation, so the
203
+ # next checkout simply tries again.
204
+ #
205
+ # Threads are created and registered inside the same critical section, so #shutdown's snapshot
206
+ # can't miss one that has started but isn't listed yet (#warm_one needs this mutex to finish,
207
+ # so it just waits for it).
208
+ def replenish_async: () -> untyped
209
+
210
+ def warm_one: () -> untyped
211
+
212
+ # A replacement warmed after #shutdown has nothing to stash into - retire it immediately rather
213
+ # than leaking a live Chrome process that nothing will ever check out. Either way this warmer's
214
+ # reservation is released here, in the same critical section as the stash.
215
+ def stash_or_retire: (untyped slot) -> untyped
216
+
217
+ def retire: (untyped slot) -> untyped
218
+
219
+ def safe_close: (untyped label) { (?) -> untyped } -> untyped
220
+ end
221
+ end
@@ -41,5 +41,5 @@ class Object
41
41
 
42
42
  def test_of_kind_present?: (untyped type) -> untyped
43
43
 
44
- def start_chromedriver_container: (build_dir: untyped, mounts: untyped, shared_network: untyped) -> untyped
44
+ def start_chromedriver_container: (build_dir: untyped, mounts: untyped, shared_network: untyped, ?chromedriver_log_level: ::String) -> untyped
45
45
  end
@@ -1,3 +1,3 @@
1
1
  module Bidi2pdf
2
- VERSION: "0.1.14"
2
+ VERSION: "0.1.16"
3
3
  end