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.
- checksums.yaml +4 -4
- data/.rubocop.yml +20 -0
- data/CHANGELOG.md +69 -2
- data/README.md +258 -10
- data/docker/Dockerfile +4 -0
- data/docker/Dockerfile.slim +5 -1
- data/lib/bidi2pdf/bidi/browser_tab.rb +45 -4
- data/lib/bidi2pdf/bidi/buffered_web_socket_client.rb +178 -0
- data/lib/bidi2pdf/bidi/client.rb +12 -5
- data/lib/bidi2pdf/bidi/commands/base.rb +2 -0
- data/lib/bidi2pdf/bidi/network_event.rb +12 -3
- data/lib/bidi2pdf/bidi/network_events.rb +9 -1
- data/lib/bidi2pdf/bidi/session.rb +1 -1
- data/lib/bidi2pdf/chromedriver_manager.rb +10 -6
- data/lib/bidi2pdf/cli/json_output.rb +30 -0
- data/lib/bidi2pdf/cli.rb +559 -17
- data/lib/bidi2pdf/diagnose.rb +119 -0
- data/lib/bidi2pdf/error_codes.rb +59 -0
- data/lib/bidi2pdf/exit_codes.rb +44 -0
- data/lib/bidi2pdf/launcher.rb +23 -0
- data/lib/bidi2pdf/manifest.rb +65 -0
- data/lib/bidi2pdf/notifications/json_subscriber.rb +78 -0
- data/lib/bidi2pdf/notifications/logging_subscriber.rb +2 -0
- data/lib/bidi2pdf/pdf_inspection.rb +62 -0
- data/lib/bidi2pdf/recipe/loader.rb +44 -0
- data/lib/bidi2pdf/recipe/runner.rb +229 -0
- data/lib/bidi2pdf/recipe/schema_shape.rb +228 -0
- data/lib/bidi2pdf/recipe/validator.rb +138 -0
- data/lib/bidi2pdf/recipe.rb +83 -0
- data/lib/bidi2pdf/result.rb +67 -0
- data/lib/bidi2pdf/result_collector.rb +150 -0
- data/lib/bidi2pdf/schema.rb +382 -0
- data/lib/bidi2pdf/session_runner.rb +42 -0
- data/lib/bidi2pdf/session_warmer.rb +377 -0
- data/lib/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rb +4 -2
- data/lib/bidi2pdf/version.rb +1 -1
- data/lib/bidi2pdf.rb +114 -9
- data/sig/bidi2pdf/bidi/browser_tab.rbs +17 -0
- data/sig/bidi2pdf/bidi/buffered_web_socket_client.rbs +81 -0
- data/sig/bidi2pdf/bidi/client.rbs +10 -2
- data/sig/bidi2pdf/bidi/network_event.rbs +11 -1
- data/sig/bidi2pdf/bidi/session.rbs +1 -1
- data/sig/bidi2pdf/chromedriver_manager.rbs +4 -0
- data/sig/bidi2pdf/cli/json_output.rbs +19 -0
- data/sig/bidi2pdf/cli.rbs +112 -2
- data/sig/bidi2pdf/diagnose.rbs +30 -0
- data/sig/bidi2pdf/error_codes.rbs +21 -0
- data/sig/bidi2pdf/exit_codes.rbs +21 -0
- data/sig/bidi2pdf/launcher.rbs +9 -0
- data/sig/bidi2pdf/manifest.rbs +38 -0
- data/sig/bidi2pdf/notifications/json_subscriber.rbs +45 -0
- data/sig/bidi2pdf/pdf_inspection.rbs +33 -0
- data/sig/bidi2pdf/recipe/loader.rbs +20 -0
- data/sig/bidi2pdf/recipe/runner.rbs +92 -0
- data/sig/bidi2pdf/recipe/schema_shape.rbs +85 -0
- data/sig/bidi2pdf/recipe/validator.rbs +54 -0
- data/sig/bidi2pdf/recipe.rbs +72 -0
- data/sig/bidi2pdf/result.rbs +71 -0
- data/sig/bidi2pdf/result_collector.rbs +106 -0
- data/sig/bidi2pdf/schema.rbs +45 -0
- data/sig/bidi2pdf/session_runner.rbs +17 -0
- data/sig/bidi2pdf/session_warmer.rbs +221 -0
- data/sig/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rbs +1 -1
- data/sig/bidi2pdf/version.rbs +1 -1
- data/sig/bidi2pdf.rbs +84 -0
- data/tasks/release_credentials_check.rake +97 -0
- 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
|