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,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Bidi2pdf
6
+ # Structured result of a CLI command (render/diagnose/run) - the shape behind --json output, a
7
+ # render manifest, and the final "result" event of --json-stream. Construct via .success/
8
+ # .failure rather than .new directly so every Result is unambiguously one or the other.
9
+ class Result
10
+ SCHEMA_VERSION = 1
11
+
12
+ attr_reader :command, :output, :bytes, :sha256, :pages, :duration_ms, :navigation,
13
+ :console, :network_failures, :warnings, :error, :metadata
14
+
15
+ # rubocop:disable-next Metrics/ParameterLists, Naming/MethodParameterName
16
+ def initialize(command:, ok:, output: nil, bytes: nil, sha256: nil, pages: nil, duration_ms: nil,
17
+ navigation: nil, console: [], network_failures: [], warnings: [], error: nil, metadata: {})
18
+ @command = command
19
+ @ok = ok
20
+ @output = output
21
+ @bytes = bytes
22
+ @sha256 = sha256
23
+ @pages = pages
24
+ @duration_ms = duration_ms
25
+ @navigation = navigation
26
+ @console = console
27
+ @network_failures = network_failures
28
+ @warnings = warnings
29
+ @error = error
30
+ @metadata = metadata
31
+ end
32
+
33
+ def self.success(**)
34
+ new(ok: true, **)
35
+ end
36
+
37
+ def self.failure(error:, **)
38
+ new(ok: false, error: error, **)
39
+ end
40
+
41
+ def ok? = @ok
42
+
43
+ def to_h
44
+ base = {
45
+ schema_version: SCHEMA_VERSION,
46
+ ok: ok?,
47
+ command: command,
48
+ output: output,
49
+ bytes: bytes,
50
+ sha256: sha256,
51
+ pages: pages,
52
+ duration_ms: duration_ms,
53
+ navigation: navigation,
54
+ console: console,
55
+ network_failures: network_failures,
56
+ warnings: warnings,
57
+ error: error
58
+ }
59
+
60
+ metadata.empty? ? base : base.merge(metadata: metadata)
61
+ end
62
+
63
+ def to_json(*)
64
+ to_h.to_json(*)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "base64"
5
+
6
+ module Bidi2pdf
7
+ # Builds a Bidi2pdf::Result by subscribing to the gem's own instrumentation events
8
+ # (Bidi2pdf::Notifications) around a block that does the actual rendering, rather than changing
9
+ # what Launcher/SessionRunner/BrowserTab return - Launcher#launch keeps returning the raw base64
10
+ # PDF when no output file is given, exactly as the programmatic API already promises (see
11
+ # spec/acceptance/launcher_spec.rb).
12
+ class ResultCollector
13
+ # #pdf_bytes is the raw, decoded PDF (nil if nothing was printed) - not part of Result#to_h
14
+ # (which only carries bytesize/sha256/pages), but what a stdout-output render (--output -)
15
+ # writes to $stdout, and what a caller can hand to Bidi2pdf::PdfInspection itself.
16
+ #
17
+ # #console/#network_failures are exposed live (not only via the Result #around eventually
18
+ # returns) so a caller running inside the block - Recipe::Runner's assertions - can read them
19
+ # before the block itself finishes.
20
+ attr_reader :requested_url, :navigation_event, :pdf_bytes, :console
21
+
22
+ def initialize(requested_url:, command:, output: nil)
23
+ @requested_url = requested_url
24
+ @command = command
25
+ @output = output
26
+ @console = []
27
+ @network_events = {}
28
+ @pdf_bytes = nil
29
+ end
30
+
31
+ # Runs the block with the collector subscribed, then returns a Bidi2pdf::Result - success with
32
+ # whatever was captured, or failure describing whatever the block raised. Never itself raises.
33
+ def around
34
+ subscribe
35
+ start = now_ms
36
+
37
+ begin
38
+ yield
39
+ build_result(ok: true, duration_ms: elapsed_ms(start))
40
+ rescue StandardError => e
41
+ build_result(ok: false, duration_ms: elapsed_ms(start), error: e)
42
+ end
43
+ ensure
44
+ unsubscribe
45
+ end
46
+
47
+ def network_failures
48
+ @network_events.values
49
+ .select { |event| event.state == "error" || (event.http_status_code && event.http_status_code >= 400) }
50
+ .map { |event| { url: event.url, method: event.http_method, status: event.http_status_code, state: event.state } }
51
+ end
52
+
53
+ private
54
+
55
+ def now_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
56
+
57
+ def elapsed_ms(start) = (now_ms - start).round
58
+
59
+ def subscribe
60
+ handlers = {
61
+ "navigate_to.bidi2pdf" => method(:on_navigate),
62
+ "render_html_content.bidi2pdf" => method(:on_navigate),
63
+ "browser_console_log_received.bidi2pdf" => method(:on_console),
64
+ "network_event_received.bidi2pdf" => method(:on_network_event),
65
+ "print.bidi2pdf" => method(:on_print)
66
+ }
67
+
68
+ # Capture the Proc #subscribe actually stored (it converts &handler into one), so
69
+ # #unsubscribe below can remove exactly this registration - passing the handler positionally
70
+ # rather than as a second &block, since Notifications#unsubscribe(pattern, block = nil)
71
+ # clears *every* subscriber for that pattern when block is nil/omitted.
72
+ @registered = handlers.map { |pattern, handler| [pattern, Bidi2pdf.notification_service.subscribe(pattern, &handler)] }
73
+ end
74
+
75
+ def unsubscribe
76
+ Array(@registered).each { |pattern, block| Bidi2pdf.notification_service.unsubscribe(pattern, block) }
77
+ end
78
+
79
+ # Kept for its #duration (used by Manifest's navigation.duration_ms); Result itself doesn't
80
+ # surface navigation timing separately from the overall duration_ms.
81
+ def on_navigate(event)
82
+ @navigation_event = event
83
+ end
84
+
85
+ def on_console(event)
86
+ payload = event.payload
87
+ @console << { level: payload[:level], text: payload[:text] }
88
+ end
89
+
90
+ def on_network_event(event)
91
+ network_event = event.payload[:event]
92
+ return unless network_event
93
+
94
+ @network_events[network_event.id] = network_event
95
+ end
96
+
97
+ def on_print(event)
98
+ pdf_base64 = event.payload[:pdf_base64]
99
+ @pdf_bytes = pdf_base64 ? Base64.decode64(pdf_base64) : nil
100
+ end
101
+
102
+ # rubocop:disable-next Naming/MethodParameterName
103
+ def build_result(ok:, duration_ms:, error: nil)
104
+ Result.new(
105
+ command: @command,
106
+ ok: ok,
107
+ output: @output,
108
+ bytes: pdf_bytes&.bytesize,
109
+ sha256: pdf_bytes ? Digest::SHA256.hexdigest(pdf_bytes) : nil,
110
+ pages: PdfInspection.page_count(pdf_bytes),
111
+ duration_ms: duration_ms,
112
+ navigation: navigation_info,
113
+ console: @console,
114
+ network_failures: network_failures,
115
+ warnings: warnings,
116
+ error: ok ? nil : ErrorCodes.describe(error)
117
+ )
118
+ end
119
+
120
+ def navigation_info
121
+ final = final_navigation_event
122
+
123
+ {
124
+ requested_url: requested_url,
125
+ final_url: final&.url || requested_url,
126
+ status: final&.http_status_code
127
+ }
128
+ end
129
+
130
+ # Best-effort external approximation of what BrowserTab#send(:correlated_navigation_response)
131
+ # already computes internally (not exposed publicly): among the network events this collector
132
+ # observed, the ones carrying a real BiDi "navigation" id are main-frame document requests: the
133
+ # one with the latest start_timestamp among those sharing the most recent such navigation id is
134
+ # the final hop of that navigation, and its own #url is therefore the page actually reached.
135
+ def final_navigation_event
136
+ candidates = @network_events.values.select { |event| event.navigation && event.http_status_code }
137
+ return nil if candidates.empty?
138
+
139
+ winning_navigation = candidates.max_by(&:start_timestamp).navigation
140
+ candidates.select { |event| event.navigation == winning_navigation }.max_by(&:start_timestamp)
141
+ end
142
+
143
+ def warnings
144
+ return [] unless pdf_bytes
145
+ return [] if PdfInspection.available?
146
+
147
+ ["pages requires the pdf-reader gem"]
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,382 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bidi2pdf
4
+ # JSON Schema documents behind `bidi2pdf schema <kind>` - how an agent discovers the shape of
5
+ # --json/--manifest/--json-stream/a recipe file without reading prose documentation. These are
6
+ # the source of truth for those shapes; a change to Result/Diagnose's payload/Recipe::Runner's
7
+ # result shape/Manifest/JsonSubscriber/Recipe's own fields should update the matching schema
8
+ # here in the same change. Kind names match the `command` field a response already carries
9
+ # (`"command": "diagnose"` -> `bidi2pdf schema diagnose`), not the Ruby class names behind them.
10
+ module Schema # rubocop:disable Metrics/ModuleLength
11
+ ERROR = {
12
+ "type" => %w[object null],
13
+ "required" => %w[code message retryable],
14
+ "properties" => {
15
+ "code" => { "type" => "string" },
16
+ "message" => { "type" => "string" },
17
+ "retryable" => { "type" => "boolean" },
18
+ "hint" => { "type" => %w[string null] },
19
+ "details" => { "type" => "object" }
20
+ }
21
+ }.freeze
22
+
23
+ NAVIGATION = {
24
+ "type" => %w[object null],
25
+ "properties" => {
26
+ "requested_url" => { "type" => %w[string null] },
27
+ "final_url" => { "type" => %w[string null] },
28
+ "status" => { "type" => %w[integer null] }
29
+ }
30
+ }.freeze
31
+
32
+ CONSOLE = {
33
+ "type" => "array",
34
+ "items" => { "type" => "object", "properties" => { "level" => { "type" => "string" }, "text" => { "type" => "string" } } }
35
+ }.freeze
36
+
37
+ NETWORK_FAILURES = {
38
+ "type" => "array",
39
+ "items" => {
40
+ "type" => "object",
41
+ "properties" => {
42
+ "url" => { "type" => "string" },
43
+ "method" => { "type" => %w[string null] },
44
+ "status" => { "type" => %w[integer null] },
45
+ "state" => { "type" => "string" }
46
+ }
47
+ }
48
+ }.freeze
49
+
50
+ WARNINGS = { "type" => "array", "items" => { "type" => "string" } }.freeze
51
+
52
+ RENDER = {
53
+ "$schema" => "https://json-schema.org/draft/2020-12/schema",
54
+ "title" => "Bidi2pdf::Result (render)",
55
+ "description" => "--json output of `bidi2pdf render`, and the final \"result\" event of its --json-stream",
56
+ "type" => "object",
57
+ "required" => %w[schema_version ok command console network_failures warnings],
58
+ "properties" => {
59
+ "schema_version" => { "const" => 1 },
60
+ "ok" => { "type" => "boolean" },
61
+ "command" => { "const" => "render" },
62
+ "output" => { "type" => %w[string null] },
63
+ "bytes" => { "type" => %w[integer null] },
64
+ "sha256" => { "type" => %w[string null] },
65
+ "pages" => { "type" => %w[integer null], "description" => "null when the pdf-reader gem is unavailable" },
66
+ "duration_ms" => { "type" => %w[integer null] },
67
+ "navigation" => NAVIGATION,
68
+ "console" => CONSOLE,
69
+ "network_failures" => NETWORK_FAILURES,
70
+ "warnings" => WARNINGS,
71
+ "error" => ERROR
72
+ }
73
+ }.freeze
74
+
75
+ DIAGNOSE = {
76
+ "$schema" => "https://json-schema.org/draft/2020-12/schema",
77
+ "title" => "bidi2pdf diagnose result",
78
+ "description" => "--json output of `bidi2pdf diagnose`, and the final \"result\" event of its --json-stream. " \
79
+ "page/fonts/print_media/paged_js are Bidi2pdf::Diagnose's own parsed script results, loosely " \
80
+ "shaped here rather than over-specified.",
81
+ "type" => "object",
82
+ "required" => %w[schema_version ok command console network_failures warnings],
83
+ "properties" => {
84
+ "schema_version" => { "const" => 1 },
85
+ "ok" => { "type" => "boolean" },
86
+ "command" => { "const" => "diagnose" },
87
+ "page" => { "type" => %w[object null] },
88
+ "console" => CONSOLE,
89
+ "network_failures" => NETWORK_FAILURES,
90
+ "fonts" => { "type" => %w[object null] },
91
+ "print_media" => { "type" => %w[object null] },
92
+ "paged_js" => { "type" => %w[object null] },
93
+ "screenshot" => { "type" => %w[string null], "description" => "path written, when --screenshot was given" },
94
+ "pdf" => {
95
+ "type" => %w[object null],
96
+ "description" => "present when --pdf was given",
97
+ "properties" => {
98
+ "path" => { "type" => "string" },
99
+ "pages" => { "type" => %w[integer null] },
100
+ "not_blank" => { "type" => %w[boolean null] }
101
+ }
102
+ },
103
+ "warnings" => WARNINGS,
104
+ "error" => ERROR
105
+ }
106
+ }.freeze
107
+
108
+ RUN_STEP = {
109
+ "type" => "object",
110
+ "required" => %w[index type ok],
111
+ "properties" => {
112
+ "index" => { "type" => "integer" },
113
+ "type" => { "type" => "string" },
114
+ "ok" => { "type" => "boolean" },
115
+ "duration_ms" => { "type" => "integer" },
116
+ "details" => { "type" => "object" }
117
+ }
118
+ }.freeze
119
+
120
+ RUN = {
121
+ "$schema" => "https://json-schema.org/draft/2020-12/schema",
122
+ "title" => "bidi2pdf run result",
123
+ "description" => "--json output of `bidi2pdf run recipe.yml`, and the final \"result\" event of its --json-stream",
124
+ "type" => "object",
125
+ "required" => %w[schema_version ok command recipe actions assertions warnings],
126
+ "properties" => {
127
+ "schema_version" => { "const" => 1 },
128
+ "ok" => { "type" => "boolean" },
129
+ "command" => { "const" => "run" },
130
+ "recipe" => { "type" => "string", "description" => "the recipe file path given on the command line" },
131
+ "actions" => { "type" => "array", "items" => RUN_STEP },
132
+ "assertions" => { "type" => "array", "items" => RUN_STEP },
133
+ "assigned" => { "type" => "object", "description" => "values captured by evaluate ... assign:, keyed by name" },
134
+ "output" => {
135
+ "type" => %w[object null],
136
+ "description" => "null when the run failed",
137
+ "properties" => {
138
+ "pdf" => { "type" => %w[string null] },
139
+ "manifest" => { "type" => %w[string null] },
140
+ "screenshot" => { "type" => %w[string null] },
141
+ "bytes" => { "type" => %w[integer null] },
142
+ "sha256" => { "type" => %w[string null] },
143
+ "pages" => { "type" => %w[integer null] }
144
+ }
145
+ },
146
+ "duration_ms" => { "type" => %w[integer null] },
147
+ "warnings" => WARNINGS,
148
+ "error" => ERROR
149
+ }
150
+ }.freeze
151
+
152
+ MANIFEST = {
153
+ "$schema" => "https://json-schema.org/draft/2020-12/schema",
154
+ "title" => "Bidi2pdf render manifest",
155
+ "description" => "--manifest FILE output",
156
+ "type" => "object",
157
+ "required" => %w[schema_version bidi2pdf_version created_at input output navigation render],
158
+ "properties" => {
159
+ "schema_version" => { "const" => 1 },
160
+ "bidi2pdf_version" => { "type" => "string" },
161
+ "created_at" => { "type" => "string", "format" => "date-time" },
162
+ "input" => {
163
+ "type" => "object",
164
+ "required" => %w[type],
165
+ "properties" => { "type" => { "type" => "string", "enum" => %w[url html_file stdin] }, "url" => { "type" => %w[string null] } }
166
+ },
167
+ "output" => {
168
+ "type" => "object",
169
+ "properties" => {
170
+ "path" => { "type" => %w[string null] },
171
+ "pages" => { "type" => %w[integer null] },
172
+ "bytes" => { "type" => %w[integer null] },
173
+ "sha256" => { "type" => %w[string null] }
174
+ }
175
+ },
176
+ "browser" => { "type" => "object" },
177
+ "navigation" => NAVIGATION,
178
+ "render" => { "type" => "object", "properties" => { "duration_ms" => { "type" => %w[integer null] } } },
179
+ "console" => CONSOLE,
180
+ "network_failures" => NETWORK_FAILURES,
181
+ "warnings" => WARNINGS,
182
+ "headers" => { "type" => "object", "description" => "present only when custom headers were given; secret-bearing values are redacted" }
183
+ }
184
+ }.freeze
185
+
186
+ EVENT = {
187
+ "$schema" => "https://json-schema.org/draft/2020-12/schema",
188
+ "title" => "Bidi2pdf --json-stream event",
189
+ "description" => "one JSON object per stderr line while --json-stream is active. In structured mode (--json/" \
190
+ "--output -), the last line is always {event: \"result\", result: <the matching command's " \
191
+ "own schema - see \"result\"'s own oneOf>}. In human mode (--json-stream given alone), only " \
192
+ "progress events are emitted - there is no synthetic final \"result\" event, since there is " \
193
+ "no structured result being built; the human-readable log is the completion/failure signal.",
194
+ "type" => "object",
195
+ "required" => %w[schema_version t_ms event],
196
+ "properties" => {
197
+ "schema_version" => { "const" => 1 },
198
+ "t_ms" => { "type" => "integer", "description" => "milliseconds since the render started" },
199
+ "event" => { "type" => "string", "enum" => %w[navigate page_loaded network_idle console print screenshot result] },
200
+ "url" => { "type" => "string", "description" => "present on a navigate event" },
201
+ "level" => { "type" => "string", "description" => "present on a console event" },
202
+ "text" => { "type" => "string", "description" => "present on a console event" },
203
+ "result" => {
204
+ "description" => "present only on the final \"result\" event (structured mode only); shape depends on which " \
205
+ "command produced this stream - each branch's own \"command\" value disambiguates",
206
+ "oneOf" => [RENDER, DIAGNOSE, RUN]
207
+ }
208
+ }
209
+ }.freeze
210
+
211
+ RECIPE_SOURCE = {
212
+ "description" => "exactly one of url, file, stdin",
213
+ "oneOf" => [
214
+ { "type" => "object", "required" => %w[url], "properties" => { "url" => { "type" => "string" } }, "additionalProperties" => false },
215
+ { "type" => "object", "required" => %w[file], "properties" => { "file" => { "type" => "string" } }, "additionalProperties" => false },
216
+ { "type" => "object", "required" => %w[stdin], "properties" => { "stdin" => { "const" => true } }, "additionalProperties" => false }
217
+ ]
218
+ }.freeze
219
+
220
+ RECIPE_ACTIONS = {
221
+ "description" => "one of the 7 known actions - a thin dispatch over BrowserTab's own methods, see README's " \
222
+ "Agent and Automation Usage section",
223
+ "oneOf" => [
224
+ {
225
+ "type" => "object", "required" => %w[wait_for], "additionalProperties" => false,
226
+ "properties" => {
227
+ "wait_for" => {
228
+ "type" => "object",
229
+ "description" => "exactly one of selector, paged_js, script; timeout is always optional",
230
+ "oneOf" => [
231
+ { "type" => "object", "required" => %w[selector], "additionalProperties" => false,
232
+ "properties" => { "selector" => { "type" => "string" }, "timeout" => { "type" => "number" } } },
233
+ { "type" => "object", "required" => %w[paged_js], "additionalProperties" => false,
234
+ "properties" => { "paged_js" => { "type" => "boolean" }, "timeout" => { "type" => "number" } } },
235
+ { "type" => "object", "required" => %w[script], "additionalProperties" => false,
236
+ "properties" => { "script" => { "type" => "string" }, "timeout" => { "type" => "number" } } }
237
+ ]
238
+ }
239
+ }
240
+ },
241
+ {
242
+ "type" => "object", "required" => %w[click], "additionalProperties" => false,
243
+ "properties" => { "click" => { "type" => "object", "required" => %w[selector], "additionalProperties" => false,
244
+ "properties" => { "selector" => { "type" => "string" } } } }
245
+ },
246
+ {
247
+ "type" => "object", "required" => %w[evaluate], "additionalProperties" => false,
248
+ "properties" => { "evaluate" => { "type" => "object", "required" => %w[script], "additionalProperties" => false,
249
+ "properties" => { "script" => { "type" => "string" }, "assign" => { "type" => "string" } } } }
250
+ },
251
+ {
252
+ "type" => "object", "required" => %w[inject_script], "additionalProperties" => false,
253
+ "properties" => { "inject_script" => { "type" => "object", "additionalProperties" => false,
254
+ "properties" => { "url" => { "type" => "string" }, "content" => { "type" => "string" },
255
+ "id" => { "type" => "string" } } } }
256
+ },
257
+ {
258
+ "type" => "object", "required" => %w[inject_style], "additionalProperties" => false,
259
+ "properties" => { "inject_style" => { "type" => "object", "additionalProperties" => false,
260
+ "properties" => { "url" => { "type" => "string" }, "content" => { "type" => "string" },
261
+ "id" => { "type" => "string" } } } }
262
+ },
263
+ {
264
+ "type" => "object", "required" => %w[set_viewport], "additionalProperties" => false,
265
+ "properties" => { "set_viewport" => { "type" => "object", "required" => %w[width height], "additionalProperties" => false,
266
+ "properties" => { "width" => { "type" => "number" }, "height" => { "type" => "number" },
267
+ "device_pixel_ratio" => { "type" => "number" } } } }
268
+ },
269
+ {
270
+ "type" => "object", "required" => %w[wait_network_idle], "additionalProperties" => false,
271
+ "properties" => { "wait_network_idle" => { "type" => "object", "additionalProperties" => false,
272
+ "properties" => { "timeout" => { "type" => "number" } } } }
273
+ }
274
+ ]
275
+ }.freeze
276
+
277
+ RECIPE_ASSERT = {
278
+ "description" => "one of the 8 known assertions. The presence-only ones (no_console_errors, no_network_failures, " \
279
+ "fonts_loaded, pdf_not_blank) never have their value read at run time - Recipe::Runner only " \
280
+ "checks the key is there - so they must be written as `true`; `false` or any other value is " \
281
+ "rejected as misleading rather than silently ignored. The last 3 assertions also require the " \
282
+ "pdf-reader gem at --validate time - a runtime fact this schema cannot express, see the " \
283
+ "top-level description",
284
+ "oneOf" => [
285
+ {
286
+ "type" => "object", "required" => %w[selector_exists], "additionalProperties" => false,
287
+ "properties" => { "selector_exists" => { "type" => "object", "required" => %w[selector], "additionalProperties" => false,
288
+ "properties" => { "selector" => { "type" => "string" } } } }
289
+ },
290
+ {
291
+ "type" => "object", "required" => %w[text_present], "additionalProperties" => false,
292
+ "properties" => { "text_present" => { "type" => "object", "required" => %w[text], "additionalProperties" => false,
293
+ "properties" => { "text" => { "type" => "string" } } } }
294
+ },
295
+ { "type" => "object", "required" => %w[no_console_errors], "additionalProperties" => false,
296
+ "properties" => { "no_console_errors" => { "const" => true } } },
297
+ { "type" => "object", "required" => %w[no_network_failures], "additionalProperties" => false,
298
+ "properties" => { "no_network_failures" => { "const" => true } } },
299
+ { "type" => "object", "required" => %w[fonts_loaded], "additionalProperties" => false,
300
+ "properties" => { "fonts_loaded" => { "const" => true } } },
301
+ {
302
+ "type" => "object", "required" => %w[page_count], "additionalProperties" => false,
303
+ "properties" => {
304
+ "page_count" => {
305
+ "oneOf" => [
306
+ { "type" => "integer" },
307
+ { "type" => "object", "properties" => { "min" => { "type" => "integer" }, "max" => { "type" => "integer" } },
308
+ "additionalProperties" => false }
309
+ ]
310
+ }
311
+ }
312
+ },
313
+ {
314
+ "type" => "object", "required" => %w[pdf_text_present], "additionalProperties" => false,
315
+ "properties" => { "pdf_text_present" => { "type" => "object", "required" => %w[text], "additionalProperties" => false,
316
+ "properties" => { "text" => { "type" => "string" } } } }
317
+ },
318
+ { "type" => "object", "required" => %w[pdf_not_blank], "additionalProperties" => false,
319
+ "properties" => { "pdf_not_blank" => { "const" => true } } }
320
+ ]
321
+ }.freeze
322
+
323
+ RECIPE_PRINT = {
324
+ "type" => "object",
325
+ "description" => "the same options `bidi2pdf render`'s own print flags map to; validated by " \
326
+ "Bidi::Commands::PrintParametersValidator",
327
+ "properties" => {
328
+ "background" => { "type" => "boolean" },
329
+ "shrinkToFit" => { "type" => "boolean" },
330
+ "orientation" => { "type" => "string", "enum" => %w[portrait landscape] },
331
+ "scale" => { "type" => "number", "minimum" => 0.1, "maximum" => 2.0 },
332
+ "pageRanges" => { "type" => "array", "items" => { "type" => %w[integer string] } },
333
+ "margin" => {
334
+ "type" => "object",
335
+ "properties" => { "top" => { "type" => "number", "minimum" => 0 }, "bottom" => { "type" => "number", "minimum" => 0 },
336
+ "left" => { "type" => "number", "minimum" => 0 }, "right" => { "type" => "number", "minimum" => 0 } }
337
+ },
338
+ "page" => {
339
+ "type" => "object",
340
+ "properties" => { "format" => { "type" => "string" }, "width" => { "type" => "number", "minimum" => 0.0352 },
341
+ "height" => { "type" => "number", "minimum" => 0.0352 } }
342
+ }
343
+ }
344
+ }.freeze
345
+
346
+ RECIPE = {
347
+ "$schema" => "https://json-schema.org/draft/2020-12/schema",
348
+ "title" => "Bidi2pdf recipe",
349
+ "description" => "input to `bidi2pdf run recipe.yml` (YAML or JSON). This schema is meant to be sufficient on " \
350
+ "its own to generate a recipe `--validate` will accept, with one exception JSON Schema cannot " \
351
+ "express: a page_count/pdf_text_present/pdf_not_blank assertion also requires the pdf-reader " \
352
+ "gem to be installed at validation time (a runtime environment fact, not a document-shape " \
353
+ "one) - `--validate` still catches a violation, with error code PDF_INSPECTION_UNAVAILABLE.",
354
+ "type" => "object",
355
+ "required" => %w[version source output],
356
+ "additionalProperties" => false,
357
+ "properties" => {
358
+ "version" => { "const" => 1 },
359
+ "source" => RECIPE_SOURCE,
360
+ "browser" => { "type" => "object", "properties" => { "headless" => { "type" => "boolean" } } },
361
+ "headers" => { "type" => "object" },
362
+ "cookies" => { "type" => "object" },
363
+ "auth" => { "type" => "object", "properties" => { "username" => { "type" => "string" }, "password" => { "type" => "string" } } },
364
+ "actions" => { "type" => "array", "items" => RECIPE_ACTIONS },
365
+ "assert" => { "type" => "array", "items" => RECIPE_ASSERT },
366
+ "print" => RECIPE_PRINT,
367
+ "output" => {
368
+ "type" => "object",
369
+ "description" => "at least one of pdf, manifest, screenshot",
370
+ "anyOf" => [{ "required" => %w[pdf] }, { "required" => %w[manifest] }, { "required" => %w[screenshot] }],
371
+ "properties" => { "pdf" => { "type" => "string" }, "manifest" => { "type" => "string" }, "screenshot" => { "type" => "string" } }
372
+ }
373
+ }
374
+ }.freeze
375
+
376
+ ALL = { "render" => RENDER, "diagnose" => DIAGNOSE, "run" => RUN, "manifest" => MANIFEST, "recipe" => RECIPE, "event" => EVENT }.freeze
377
+
378
+ def self.for(kind)
379
+ ALL.fetch(kind.to_s) { raise ArgumentError, "Unknown schema '#{kind}' - known: #{ALL.keys.join(", ")}" }
380
+ end
381
+ end
382
+ end
@@ -60,6 +60,29 @@ module Bidi2pdf
60
60
  run_flow
61
61
  end
62
62
 
63
+ # Like #run, but stops after navigation (and the configured waits) instead of printing, and
64
+ # leaves the tab open for the caller to inspect - used by `bidi2pdf diagnose`, which has no PDF
65
+ # to produce. The caller is responsible for #close_all once done.
66
+ #
67
+ # @return [Bidi2pdf::Bidi::BrowserTab] the navigated tab.
68
+ def run_diagnose
69
+ @session.start
70
+ @session.client.on_close { Bidi2pdf.logger.info "WebSocket closed" }
71
+
72
+ setup_browser
73
+ navigate_and_wait
74
+
75
+ @tab
76
+ end
77
+
78
+ # Closes what #run_diagnose opened. #run's own run_flow already does this itself (in its
79
+ # ensure), so this is only ever needed after #run_diagnose.
80
+ def close_all
81
+ @tab&.close
82
+ @window&.close
83
+ @user_context&.close
84
+ end
85
+
63
86
  private
64
87
 
65
88
  def setup_browser
@@ -109,6 +132,25 @@ module Bidi2pdf
109
132
  )
110
133
  end
111
134
 
135
+ # Navigate + wait only, no log_network_traffic/print side effects - #run_flow above
136
+ # intentionally keeps its own inline sequence (where log_network_traffic sits between the two
137
+ # waits) untouched rather than sharing this, since that render-only file-writing side effect
138
+ # has no place in a diagnose flow.
139
+ def navigate_and_wait
140
+ @session.status
141
+ @session.user_contexts
142
+
143
+ if @url
144
+ @tab.navigate_to(@url)
145
+ else
146
+ Bidi2pdf.logger.info "Loading HTML file #{@inputfile}"
147
+ @tab.render_html_content(File.read(@inputfile))
148
+ end
149
+
150
+ @tab.wait_until_network_idle if @wait_network_idle
151
+ @tab.wait_until_page_loaded if @wait_window_loaded
152
+ end
153
+
112
154
  # rubocop: disable Metrics/AbcSize
113
155
  def run_flow
114
156
  @session.status