probatio_diabolica 0.4.3 → 0.4.5
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/README.md +12 -0
- data/bin/prd +60 -12
- data/bin/prd_mcp +2 -0
- data/lib/pr_d/formatters/formatter.rb +14 -0
- data/lib/pr_d/formatters/html_formatter.rb +197 -43
- data/lib/pr_d/formatters/pdf_formatter.rb +3 -1
- data/lib/pr_d/helpers/chrome_helper.rb +13 -0
- data/lib/pr_d/mcp/run_specs_tool.rb +16 -1
- data/lib/pr_d/mcp/server.rb +13 -1
- data/lib/pr_d/report_collector.rb +132 -0
- data/lib/pr_d/report_model.rb +181 -0
- data/lib/pr_d/report_renderer.rb +20 -0
- data/lib/pr_d/version.rb +1 -1
- data/lib/pr_d/worker_runner.rb +103 -0
- data/lib/pr_d.rb +74 -31
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0a935022c47a87f28cab73012b6c96563616958a6eb9ff28d982ed7dc6b7abdd
|
|
4
|
+
data.tar.gz: 7b1ad8ea3bc58ce2a167508d95a8e63f574078e4f7b06e007f8650185cfc43fb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1a918c5542c8fb74413fd7e8fc2536767469f86833a0ec78ebe3a444156a02431d64e7c573fe10804e2ed008d53abf5ca187754fdae1fbb3ad3d416b6eec2c3c
|
|
7
|
+
data.tar.gz: 31e087b60fd08046dd952dda13e3ab30599b8f04ebc311a622bf26ada5950f00f7f81563c7ddd813c6725c5e992c8b2b7091366fea9338553b219baa8fa44f1c
|
data/README.md
CHANGED
|
@@ -79,6 +79,7 @@ Input:
|
|
|
79
79
|
- `out` (optional): same as `-o`
|
|
80
80
|
- `formatters` (optional): array of `simple|html|json|pdf` (default: `["simple"]`)
|
|
81
81
|
- `mode` (optional): `verbose|synthetic` (default: `synthetic`)
|
|
82
|
+
- `jobs` (optional): file-level workers count (integer >= 1, default: `1`)
|
|
82
83
|
|
|
83
84
|
Output (`structuredContent`):
|
|
84
85
|
- `ok`, `exit_code`
|
|
@@ -95,6 +96,7 @@ Options:
|
|
|
95
96
|
- `-o, --out PATH` output base path (directory or file-like base name)
|
|
96
97
|
- `-m, --mode MODE` output mode, default: `verbose`
|
|
97
98
|
- supported: `verbose`, `synthetic`
|
|
99
|
+
- `-j, --jobs N` number of workers for file-level execution, default: `1`
|
|
98
100
|
|
|
99
101
|
Output rules (`--out`):
|
|
100
102
|
|
|
@@ -131,8 +133,18 @@ prd examples/basics_spec.rb -t html,json,pdf -o ./tmp/my_report
|
|
|
131
133
|
|
|
132
134
|
# compact synthetic output on console
|
|
133
135
|
prd examples/basics_spec.rb --mode synthetic
|
|
136
|
+
|
|
137
|
+
# run a directory with 4 file-level workers
|
|
138
|
+
prd spec --mode synthetic --jobs 4
|
|
134
139
|
```
|
|
135
140
|
|
|
141
|
+
Parallel execution notes:
|
|
142
|
+
|
|
143
|
+
- Parallelism is file-level only (`*_spec.rb` files are distributed across workers).
|
|
144
|
+
- Event rendering order stays deterministic: files are merged in sorted path order.
|
|
145
|
+
- `--jobs 1` uses the same worker pipeline as `--jobs N`.
|
|
146
|
+
- Specs sharing mutable global state across files can become non-deterministic when `jobs > 1`.
|
|
147
|
+
|
|
136
148
|
## Available DSL
|
|
137
149
|
|
|
138
150
|
It is inspired by RSpec but with a custom runtime and additional features.
|
data/bin/prd
CHANGED
|
@@ -7,6 +7,7 @@ rescue LoadError
|
|
|
7
7
|
end
|
|
8
8
|
require 'optparse'
|
|
9
9
|
require 'fileutils'
|
|
10
|
+
require 'stringio'
|
|
10
11
|
|
|
11
12
|
FORMATTERS = {
|
|
12
13
|
'simple' => { klass: PrD::Formatters::SimpleFormatter, extension: '.txt', binary: false },
|
|
@@ -18,6 +19,7 @@ FORMATTERS = {
|
|
|
18
19
|
SUPPORTED_FORMATTERS = FORMATTERS.keys.freeze
|
|
19
20
|
SUPPORTED_MODES = %w[verbose synthetic].freeze
|
|
20
21
|
DEFAULT_REPORT_BASENAME = 'report'.freeze
|
|
22
|
+
DEFAULT_JOBS = 1
|
|
21
23
|
|
|
22
24
|
class CompositeFormatter
|
|
23
25
|
def initialize(formatters)
|
|
@@ -67,6 +69,17 @@ def normalize_mode(raw_mode)
|
|
|
67
69
|
mode
|
|
68
70
|
end
|
|
69
71
|
|
|
72
|
+
def normalize_jobs(raw_jobs)
|
|
73
|
+
return DEFAULT_JOBS if raw_jobs.nil?
|
|
74
|
+
|
|
75
|
+
jobs = Integer(raw_jobs)
|
|
76
|
+
raise "Invalid jobs count: #{jobs}. Expected an integer greater than or equal to 1." if jobs < 1
|
|
77
|
+
|
|
78
|
+
jobs
|
|
79
|
+
rescue ArgumentError, TypeError
|
|
80
|
+
raise "Invalid jobs value: #{raw_jobs}. Expected an integer greater than or equal to 1."
|
|
81
|
+
end
|
|
82
|
+
|
|
70
83
|
def directory_like_path?(path)
|
|
71
84
|
path.end_with?(File::SEPARATOR) || Dir.exist?(path)
|
|
72
85
|
end
|
|
@@ -97,6 +110,9 @@ OptionParser
|
|
|
97
110
|
opts.on('-m', '--mode MODE', 'Specify output mode (verbose, synthetic)') do |mode|
|
|
98
111
|
options[:mode] = mode
|
|
99
112
|
end
|
|
113
|
+
opts.on('-j', '--jobs N', 'Specify number of workers (default: 1)') do |jobs|
|
|
114
|
+
options[:jobs] = jobs
|
|
115
|
+
end
|
|
100
116
|
end
|
|
101
117
|
.parse!
|
|
102
118
|
|
|
@@ -105,17 +121,18 @@ raise 'No tests found. Please specify a test file or directory (e.g. prd example
|
|
|
105
121
|
raise "Unexpected arguments: #{ARGV.join(' ')}" unless ARGV.empty?
|
|
106
122
|
raise "Path not found: #{input_path}" unless File.exist?(input_path)
|
|
107
123
|
|
|
108
|
-
|
|
124
|
+
test_files =
|
|
109
125
|
if File.directory?(input_path)
|
|
110
126
|
files = Dir[File.join(input_path, '**', '*_spec.rb')].sort
|
|
111
127
|
raise "No spec files found in directory: #{input_path}" if files.empty?
|
|
112
|
-
files
|
|
128
|
+
files
|
|
113
129
|
elsif File.file?(input_path)
|
|
114
|
-
[
|
|
130
|
+
[input_path]
|
|
115
131
|
end
|
|
116
132
|
|
|
117
133
|
formatter_types = parse_formatter_types(options[:formatters])
|
|
118
134
|
mode = normalize_mode(options[:mode])
|
|
135
|
+
jobs = normalize_jobs(options[:jobs])
|
|
119
136
|
out_base = output_base_path(options[:out])
|
|
120
137
|
|
|
121
138
|
if formatter_types.include?('pdf') && out_base.nil?
|
|
@@ -133,7 +150,7 @@ serializers = {
|
|
|
133
150
|
|
|
134
151
|
destinations =
|
|
135
152
|
if out_base.nil?
|
|
136
|
-
[{ io: STDOUT,
|
|
153
|
+
[{ io: STDOUT, formatter_type: formatter_types.first }]
|
|
137
154
|
else
|
|
138
155
|
output_dir = File.dirname(out_base)
|
|
139
156
|
FileUtils.mkdir_p(output_dir)
|
|
@@ -141,21 +158,52 @@ destinations =
|
|
|
141
158
|
config = FORMATTERS.fetch(type)
|
|
142
159
|
output_path = "#{out_base}#{config[:extension]}"
|
|
143
160
|
io = File.open(output_path, config[:binary] ? 'wb' : 'w')
|
|
144
|
-
{ io:,
|
|
161
|
+
{ io:, formatter_type: type }
|
|
145
162
|
end
|
|
146
163
|
end
|
|
147
164
|
|
|
148
|
-
|
|
149
|
-
if
|
|
150
|
-
|
|
165
|
+
subject_display_strategy =
|
|
166
|
+
if formatter_types.length == 1
|
|
167
|
+
FORMATTERS.fetch(formatter_types.first)[:klass].new(io: StringIO.new, serializers:, mode: mode.to_sym).subject_display_strategy
|
|
168
|
+
else
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
eager_subject_display_strategy =
|
|
173
|
+
if formatter_types.length == 1
|
|
174
|
+
FORMATTERS.fetch(formatter_types.first)[:klass].new(io: StringIO.new, serializers:, mode: mode.to_sym).eager_subject_display_strategy
|
|
151
175
|
else
|
|
152
|
-
|
|
176
|
+
nil
|
|
153
177
|
end
|
|
154
178
|
|
|
179
|
+
collector = PrD::ReportCollector.new(
|
|
180
|
+
io: StringIO.new,
|
|
181
|
+
serializers: serializers,
|
|
182
|
+
mode: mode.to_sym,
|
|
183
|
+
subject_display_strategy: subject_display_strategy,
|
|
184
|
+
eager_subject_display_strategy: eager_subject_display_strategy
|
|
185
|
+
)
|
|
186
|
+
|
|
155
187
|
runtime_output_dir = out_base.nil? ? nil : File.dirname(out_base)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
188
|
+
runner_result = PrD::WorkerRunner.new(
|
|
189
|
+
file_paths: test_files,
|
|
190
|
+
jobs: jobs,
|
|
191
|
+
mode: mode.to_sym,
|
|
192
|
+
serializers: serializers,
|
|
193
|
+
output_dir: runtime_output_dir,
|
|
194
|
+
config_file: options[:config],
|
|
195
|
+
subject_display_strategy: collector.subject_display_strategy,
|
|
196
|
+
eager_subject_display_strategy: collector.eager_subject_display_strategy
|
|
197
|
+
).run
|
|
198
|
+
exit_status = runner_result.failed_count.zero? ? 0 : 1
|
|
199
|
+
|
|
200
|
+
destinations.each do |destination|
|
|
201
|
+
io = destination[:io]
|
|
202
|
+
type = destination[:formatter_type]
|
|
203
|
+
formatter = FORMATTERS.fetch(type)[:klass].new(io:, serializers:, mode: mode.to_sym)
|
|
204
|
+
PrD::ReportRenderer.render(runner_result.model, formatter)
|
|
205
|
+
end
|
|
206
|
+
|
|
159
207
|
destinations.each do |destination|
|
|
160
208
|
io = destination[:io]
|
|
161
209
|
io.close if io != STDOUT
|
data/bin/prd_mcp
CHANGED
|
@@ -117,6 +117,7 @@ module PrD
|
|
|
117
117
|
serializer = @serializers[value.class]
|
|
118
118
|
return serializer.call(value) if serializer
|
|
119
119
|
return ferrum_node_summary(value) if ferrum_node?(value)
|
|
120
|
+
return value.path if value.respond_to?(:path) && value.path.is_a?(String) && !value.path.empty?
|
|
120
121
|
return value.path if value.is_a?(File)
|
|
121
122
|
return value.map { |v| serialize(v) } if value.is_a?(Array)
|
|
122
123
|
return value.transform_values { |v| serialize(v) } if value.is_a?(Hash)
|
|
@@ -129,12 +130,25 @@ module PrD
|
|
|
129
130
|
end
|
|
130
131
|
|
|
131
132
|
def ferrum_node?(value)
|
|
133
|
+
return true if defined?(PrD::ReportModel::FerrumNodeSnapshot) && value.is_a?(PrD::ReportModel::FerrumNodeSnapshot)
|
|
134
|
+
|
|
132
135
|
value.respond_to?(:class) && value.class.respond_to?(:name) && value.class.name == 'Ferrum::Node'
|
|
133
136
|
rescue StandardError
|
|
134
137
|
false
|
|
135
138
|
end
|
|
136
139
|
|
|
137
140
|
def ferrum_node_payload(node)
|
|
141
|
+
if defined?(PrD::ReportModel::FerrumNodeSnapshot) && node.is_a?(PrD::ReportModel::FerrumNodeSnapshot)
|
|
142
|
+
payload = node.payload || {}
|
|
143
|
+
payload[:tag] = payload[:tag].to_s.downcase.strip unless blank_text?(payload[:tag])
|
|
144
|
+
payload[:id] = payload[:id].to_s.strip unless blank_text?(payload[:id])
|
|
145
|
+
payload[:classes] = normalize_classes(payload[:classes])
|
|
146
|
+
payload[:text] = normalize_preview_text(payload[:text], max_length: 160)
|
|
147
|
+
payload[:html] = normalize_preview_text(payload[:html], max_length: 220)
|
|
148
|
+
payload[:description] = normalize_preview_text(payload[:description], max_length: 160)
|
|
149
|
+
return payload
|
|
150
|
+
end
|
|
151
|
+
|
|
138
152
|
payload = ferrum_node_payload_from_js(node) || {}
|
|
139
153
|
payload[:tag] = payload[:tag].to_s.downcase.strip unless blank_text?(payload[:tag])
|
|
140
154
|
payload[:id] = payload[:id].to_s.strip unless blank_text?(payload[:id])
|
|
@@ -12,12 +12,20 @@ module PrD
|
|
|
12
12
|
@anchor_counters = Hash.new(0)
|
|
13
13
|
@rouge_formatter = Rouge::Formatters::HTMLLegacy.new(css_class: 'highlight')
|
|
14
14
|
@pending_expectation = nil
|
|
15
|
+
@open_let_group_level = nil
|
|
16
|
+
@pending_scope_type = nil
|
|
17
|
+
@scope_stack = []
|
|
18
|
+
@open_context_blocks = 0
|
|
15
19
|
end
|
|
16
20
|
|
|
17
21
|
def context(message)
|
|
22
|
+
close_let_group_if_open
|
|
18
23
|
anchor_id = next_anchor_id('ctx')
|
|
19
24
|
add_index_entry(type: :context, label: message, level: @level, anchor_id:)
|
|
25
|
+
@content << %(<section class="context-block" style="--ctx-level: #{@level};">)
|
|
20
26
|
@content << "<h2 class=\"context\" id=\"#{anchor_id}\">#{escape(message)}</h2>"
|
|
27
|
+
@open_context_blocks += 1
|
|
28
|
+
@pending_scope_type = :context
|
|
21
29
|
end
|
|
22
30
|
|
|
23
31
|
def success_result(message)
|
|
@@ -37,12 +45,14 @@ module PrD
|
|
|
37
45
|
end
|
|
38
46
|
|
|
39
47
|
def it(description = nil, &block)
|
|
48
|
+
close_let_group_if_open
|
|
40
49
|
@current_test_title = description.to_s
|
|
41
50
|
@pending_expectation = nil
|
|
42
51
|
anchor_id = next_anchor_id('test')
|
|
43
52
|
add_index_entry(type: :test, label: description.to_s, level: @level, anchor_id:)
|
|
44
53
|
@content << "<article class=\"test-card\" id=\"#{anchor_id}\">"
|
|
45
54
|
@content << "<h3 class=\"test-title\">#{escape(description)}</h3>"
|
|
55
|
+
@pending_scope_type = :it
|
|
46
56
|
end
|
|
47
57
|
|
|
48
58
|
def end_it(description = nil, &block)
|
|
@@ -56,6 +66,7 @@ module PrD
|
|
|
56
66
|
|
|
57
67
|
def let(name_or_value, value = MISSING_VALUE)
|
|
58
68
|
return if synthetic?
|
|
69
|
+
start_let_group_if_needed
|
|
59
70
|
name, rendered_value = named_value_arguments(name_or_value, value)
|
|
60
71
|
label = name.nil? ? 'Let' : "Let(:#{name})"
|
|
61
72
|
render_collapsible_let_block(label, rendered_value)
|
|
@@ -63,6 +74,7 @@ module PrD
|
|
|
63
74
|
|
|
64
75
|
def subject(subject)
|
|
65
76
|
return if synthetic?
|
|
77
|
+
close_let_group_if_open
|
|
66
78
|
render_value_block('Subject', subject)
|
|
67
79
|
end
|
|
68
80
|
|
|
@@ -75,6 +87,7 @@ module PrD
|
|
|
75
87
|
end
|
|
76
88
|
|
|
77
89
|
def pending(description = nil)
|
|
90
|
+
close_let_group_if_open
|
|
78
91
|
pending_label = description || 'Pending test'
|
|
79
92
|
anchor_id = next_anchor_id('pending')
|
|
80
93
|
add_index_entry(type: :pending, label: pending_label, level: @level, anchor_id:)
|
|
@@ -122,6 +135,8 @@ module PrD
|
|
|
122
135
|
end
|
|
123
136
|
|
|
124
137
|
def flush
|
|
138
|
+
close_let_group_if_open
|
|
139
|
+
close_all_context_blocks
|
|
125
140
|
@io << document_opening
|
|
126
141
|
@io << render_index
|
|
127
142
|
@io << @content
|
|
@@ -129,6 +144,21 @@ module PrD
|
|
|
129
144
|
super
|
|
130
145
|
end
|
|
131
146
|
|
|
147
|
+
def increment_level
|
|
148
|
+
close_let_group_if_open if @open_let_group_level == @level
|
|
149
|
+
@scope_stack << (@pending_scope_type || :unknown)
|
|
150
|
+
@pending_scope_type = nil
|
|
151
|
+
super
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def decrement_level
|
|
155
|
+
close_let_group_if_open if !@open_let_group_level.nil? && @open_let_group_level >= @level
|
|
156
|
+
scope = @scope_stack.pop
|
|
157
|
+
@content << '</section>' if scope == :context && @open_context_blocks.positive?
|
|
158
|
+
@open_context_blocks -= 1 if scope == :context && @open_context_blocks.positive?
|
|
159
|
+
super
|
|
160
|
+
end
|
|
161
|
+
|
|
132
162
|
private
|
|
133
163
|
|
|
134
164
|
def document_opening
|
|
@@ -168,7 +198,6 @@ module PrD
|
|
|
168
198
|
margin: 1rem auto 2rem;
|
|
169
199
|
padding: 1rem;
|
|
170
200
|
background: var(--paper);
|
|
171
|
-
border: 1px solid var(--line);
|
|
172
201
|
border-radius: 4px;
|
|
173
202
|
}
|
|
174
203
|
|
|
@@ -205,7 +234,6 @@ module PrD
|
|
|
205
234
|
z-index: 1000;
|
|
206
235
|
width: var(--sidebar-width);
|
|
207
236
|
background: var(--paper);
|
|
208
|
-
border: 1px solid var(--line);
|
|
209
237
|
border-radius: 4px;
|
|
210
238
|
padding: 0.9rem 1rem;
|
|
211
239
|
overflow-y: auto;
|
|
@@ -234,8 +262,31 @@ module PrD
|
|
|
234
262
|
padding-left: calc(var(--index-level, 0) * 1rem);
|
|
235
263
|
}
|
|
236
264
|
|
|
265
|
+
.index-row {
|
|
266
|
+
display: flex;
|
|
267
|
+
align-items: center;
|
|
268
|
+
gap: 0.3rem;
|
|
269
|
+
min-width: 0;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.context-toggle {
|
|
273
|
+
border: 1px solid var(--line);
|
|
274
|
+
border-radius: 3px;
|
|
275
|
+
background: #fff;
|
|
276
|
+
color: var(--accent);
|
|
277
|
+
cursor: pointer;
|
|
278
|
+
font-size: 0.72rem;
|
|
279
|
+
width: 1.2rem;
|
|
280
|
+
height: 1.2rem;
|
|
281
|
+
line-height: 1;
|
|
282
|
+
padding: 0;
|
|
283
|
+
flex: 0 0 auto;
|
|
284
|
+
}
|
|
285
|
+
|
|
237
286
|
.index-link {
|
|
238
287
|
display: block;
|
|
288
|
+
flex: 1 1 auto;
|
|
289
|
+
min-width: 0;
|
|
239
290
|
white-space: nowrap;
|
|
240
291
|
overflow: hidden;
|
|
241
292
|
text-overflow: ellipsis;
|
|
@@ -249,21 +300,39 @@ module PrD
|
|
|
249
300
|
|
|
250
301
|
.context {
|
|
251
302
|
font-size: 1.25rem;
|
|
252
|
-
margin:
|
|
303
|
+
margin: 0.45rem 0 0.6rem;
|
|
253
304
|
padding-bottom: 0.35rem;
|
|
254
305
|
border-bottom: 1px solid var(--line);
|
|
255
306
|
}
|
|
256
307
|
|
|
308
|
+
.context-block {
|
|
309
|
+
margin: 0 0 0.5rem;
|
|
310
|
+
margin-left: calc(var(--ctx-level, 0) * 0.6rem);
|
|
311
|
+
padding-left: 0.55rem;
|
|
312
|
+
border-left: 1px solid #e5e7eb;
|
|
313
|
+
}
|
|
314
|
+
|
|
257
315
|
.test-card {
|
|
258
|
-
background:
|
|
259
|
-
border:
|
|
260
|
-
|
|
261
|
-
padding: 0.9rem 1rem;
|
|
316
|
+
background: transparent;
|
|
317
|
+
border-left: 3px solid #d1d5db;
|
|
318
|
+
padding: 0.8rem 0.95rem;
|
|
262
319
|
margin: 0 0 1rem;
|
|
263
320
|
}
|
|
264
321
|
|
|
322
|
+
.test-card:has(.status.success) {
|
|
323
|
+
border-left-color: #16a34a;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.test-card:has(.status.failure) {
|
|
327
|
+
border-left-color: #dc2626;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.test-card:has(.status.pending) {
|
|
331
|
+
border-left-color: #d97706;
|
|
332
|
+
}
|
|
333
|
+
|
|
265
334
|
.test-title {
|
|
266
|
-
margin: 0 0 0.
|
|
335
|
+
margin: 0 0 0.5rem;
|
|
267
336
|
font-size: 1.1rem;
|
|
268
337
|
}
|
|
269
338
|
|
|
@@ -315,53 +384,65 @@ module PrD
|
|
|
315
384
|
}
|
|
316
385
|
|
|
317
386
|
.status {
|
|
318
|
-
margin: 0.
|
|
319
|
-
padding: 0
|
|
320
|
-
border
|
|
321
|
-
border:
|
|
322
|
-
font-weight:
|
|
387
|
+
margin: 0.22rem 0 0;
|
|
388
|
+
padding: 0;
|
|
389
|
+
border: 0;
|
|
390
|
+
border-radius: 0;
|
|
391
|
+
font-weight: 400;
|
|
392
|
+
font-size: 0.76rem;
|
|
393
|
+
letter-spacing: 0.01em;
|
|
394
|
+
color: var(--muted);
|
|
395
|
+
opacity: 0.8;
|
|
323
396
|
}
|
|
324
397
|
|
|
325
398
|
.status.success {
|
|
326
|
-
background:
|
|
327
|
-
color: var(--
|
|
399
|
+
background: transparent;
|
|
400
|
+
color: var(--muted);
|
|
328
401
|
}
|
|
329
402
|
|
|
330
403
|
.status.failure {
|
|
331
|
-
background:
|
|
332
|
-
color: var(--
|
|
404
|
+
background: transparent;
|
|
405
|
+
color: var(--muted);
|
|
333
406
|
}
|
|
334
407
|
|
|
335
408
|
.status.pending {
|
|
336
|
-
background:
|
|
337
|
-
color: var(--
|
|
409
|
+
background: transparent;
|
|
410
|
+
color: var(--muted);
|
|
338
411
|
}
|
|
339
412
|
|
|
340
413
|
.subject-block {
|
|
341
|
-
margin: 0.
|
|
342
|
-
padding: 0.
|
|
414
|
+
margin: 0.18rem 0 0.34rem;
|
|
415
|
+
padding: 0.36rem 0.5rem;
|
|
343
416
|
border: 1px solid #d1d5db;
|
|
344
417
|
border-radius: 4px;
|
|
345
|
-
background: #
|
|
418
|
+
background: #fcfcfc;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
.lets-group {
|
|
422
|
+
margin: 0.2rem 0 0.6rem;
|
|
423
|
+
padding: 0.18rem 0.38rem;
|
|
424
|
+
background: #fcfcfc;
|
|
425
|
+
border-left: 2px solid #e5e7eb;
|
|
426
|
+
border-radius: 3px;
|
|
346
427
|
}
|
|
347
428
|
|
|
348
429
|
.let-block {
|
|
349
|
-
margin: 0.
|
|
350
|
-
border:
|
|
351
|
-
border-radius:
|
|
352
|
-
background:
|
|
430
|
+
margin: 0.15rem 0;
|
|
431
|
+
border: 0;
|
|
432
|
+
border-radius: 0;
|
|
433
|
+
background: transparent;
|
|
353
434
|
}
|
|
354
435
|
|
|
355
436
|
.let-toggle {
|
|
356
437
|
list-style: none;
|
|
357
438
|
cursor: pointer;
|
|
358
|
-
padding: 0.
|
|
439
|
+
padding: 0.24rem 0.26rem;
|
|
359
440
|
display: flex;
|
|
360
441
|
align-items: center;
|
|
361
442
|
justify-content: space-between;
|
|
362
|
-
gap: 0.
|
|
363
|
-
font-size: 0.
|
|
364
|
-
font-weight:
|
|
443
|
+
gap: 0.4rem;
|
|
444
|
+
font-size: 0.82rem;
|
|
445
|
+
font-weight: 500;
|
|
365
446
|
color: #0f172a;
|
|
366
447
|
}
|
|
367
448
|
|
|
@@ -372,7 +453,7 @@ module PrD
|
|
|
372
453
|
.let-toggle::after {
|
|
373
454
|
content: 'Open';
|
|
374
455
|
color: var(--muted);
|
|
375
|
-
font-size: 0.
|
|
456
|
+
font-size: 0.68rem;
|
|
376
457
|
letter-spacing: 0.03em;
|
|
377
458
|
text-transform: uppercase;
|
|
378
459
|
}
|
|
@@ -382,7 +463,7 @@ module PrD
|
|
|
382
463
|
}
|
|
383
464
|
|
|
384
465
|
.let-content {
|
|
385
|
-
padding: 0 0.
|
|
466
|
+
padding: 0 0.28rem 0.12rem;
|
|
386
467
|
}
|
|
387
468
|
|
|
388
469
|
.let-content .subject-block {
|
|
@@ -526,12 +607,26 @@ module PrD
|
|
|
526
607
|
|
|
527
608
|
index_items = @index_entries.map do |entry|
|
|
528
609
|
label = escape(index_label(entry))
|
|
529
|
-
|
|
610
|
+
type = entry[:type].to_s
|
|
611
|
+
toggle =
|
|
612
|
+
if entry[:type] == :context
|
|
613
|
+
'<button type="button" class="context-toggle" aria-expanded="false" title="Unfold context">+</button>'
|
|
614
|
+
else
|
|
615
|
+
''
|
|
616
|
+
end
|
|
617
|
+
<<~HTML.chomp
|
|
618
|
+
<li class="index-item" style="--index-level: #{entry[:level]};" data-level="#{entry[:level]}" data-type="#{type}">
|
|
619
|
+
<div class="index-row">
|
|
620
|
+
#{toggle}
|
|
621
|
+
<a class="index-link" href="##{entry[:anchor_id]}" title="#{label}">#{label}</a>
|
|
622
|
+
</div>
|
|
623
|
+
</li>
|
|
624
|
+
HTML
|
|
530
625
|
end.join
|
|
531
626
|
|
|
532
627
|
<<~HTML
|
|
533
|
-
<button type="button" class="index-toggle" aria-expanded="false"
|
|
534
|
-
<nav
|
|
628
|
+
<button type="button" class="index-toggle" aria-expanded="false">Show index</button>
|
|
629
|
+
<nav class="report-index" aria-label="Report index">
|
|
535
630
|
<h2 class="index-title">Index</h2>
|
|
536
631
|
<ul class="index-list">
|
|
537
632
|
#{index_items}
|
|
@@ -540,7 +635,7 @@ module PrD
|
|
|
540
635
|
<script>
|
|
541
636
|
(function() {
|
|
542
637
|
var body = document.body;
|
|
543
|
-
var nav = document.
|
|
638
|
+
var nav = document.querySelector('.report-index');
|
|
544
639
|
var toggle = document.querySelector('.index-toggle');
|
|
545
640
|
if (!body || !nav || !toggle) return;
|
|
546
641
|
|
|
@@ -557,6 +652,49 @@ module PrD
|
|
|
557
652
|
syncToggleLabel();
|
|
558
653
|
});
|
|
559
654
|
|
|
655
|
+
var items = Array.prototype.slice.call(nav.querySelectorAll('.index-item'));
|
|
656
|
+
var contextButtons = Array.prototype.slice.call(nav.querySelectorAll('.context-toggle'));
|
|
657
|
+
|
|
658
|
+
var levelOf = function(item) {
|
|
659
|
+
return Number(item.getAttribute('data-level') || '0');
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
var descendantsOf = function(parentItem) {
|
|
663
|
+
var start = items.indexOf(parentItem);
|
|
664
|
+
if (start < 0) return [];
|
|
665
|
+
var parentLevel = levelOf(parentItem);
|
|
666
|
+
var descendants = [];
|
|
667
|
+
|
|
668
|
+
for (var i = start + 1; i < items.length; i += 1) {
|
|
669
|
+
var candidate = items[i];
|
|
670
|
+
if (levelOf(candidate) <= parentLevel) break;
|
|
671
|
+
descendants.push(candidate);
|
|
672
|
+
}
|
|
673
|
+
return descendants;
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
contextButtons.forEach(function(button) {
|
|
677
|
+
var contextItem = button.closest('.index-item');
|
|
678
|
+
if (!contextItem) return;
|
|
679
|
+
|
|
680
|
+
button.addEventListener('click', function(event) {
|
|
681
|
+
event.preventDefault();
|
|
682
|
+
var expanded = button.getAttribute('aria-expanded') !== 'false';
|
|
683
|
+
var nextExpanded = !expanded;
|
|
684
|
+
button.setAttribute('aria-expanded', String(nextExpanded));
|
|
685
|
+
button.textContent = nextExpanded ? '-' : '+';
|
|
686
|
+
button.title = nextExpanded ? 'Fold context' : 'Unfold context';
|
|
687
|
+
|
|
688
|
+
descendantsOf(contextItem).forEach(function(item) {
|
|
689
|
+
item.style.display = nextExpanded ? '' : 'none';
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
descendantsOf(contextItem).forEach(function(item) {
|
|
694
|
+
item.style.display = 'none';
|
|
695
|
+
});
|
|
696
|
+
});
|
|
697
|
+
|
|
560
698
|
syncToggleLabel();
|
|
561
699
|
})();
|
|
562
700
|
</script>
|
|
@@ -564,13 +702,7 @@ module PrD
|
|
|
564
702
|
end
|
|
565
703
|
|
|
566
704
|
def index_label(entry)
|
|
567
|
-
|
|
568
|
-
case entry[:type]
|
|
569
|
-
when :context then '+'
|
|
570
|
-
else '-'
|
|
571
|
-
end
|
|
572
|
-
|
|
573
|
-
"#{marker} #{entry[:label]}"
|
|
705
|
+
"#{entry[:label]}"
|
|
574
706
|
end
|
|
575
707
|
|
|
576
708
|
def add_index_entry(type:, label:, level:, anchor_id:)
|
|
@@ -738,6 +870,28 @@ module PrD
|
|
|
738
870
|
render_expectation_code_block('Expected', expected_value) if expected_present
|
|
739
871
|
end
|
|
740
872
|
|
|
873
|
+
def start_let_group_if_needed
|
|
874
|
+
return if @open_let_group_level == @level
|
|
875
|
+
|
|
876
|
+
close_let_group_if_open
|
|
877
|
+
@content << '<section class="lets-group">'
|
|
878
|
+
@open_let_group_level = @level
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
def close_let_group_if_open
|
|
882
|
+
return if @open_let_group_level.nil?
|
|
883
|
+
|
|
884
|
+
@content << '</section>'
|
|
885
|
+
@open_let_group_level = nil
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
def close_all_context_blocks
|
|
889
|
+
while @open_context_blocks.positive?
|
|
890
|
+
@content << '</section>'
|
|
891
|
+
@open_context_blocks -= 1
|
|
892
|
+
end
|
|
893
|
+
end
|
|
894
|
+
|
|
741
895
|
def expectation_inline_value(value)
|
|
742
896
|
return "(#{normalize_text(value.language)} code)" if code_object?(value)
|
|
743
897
|
|
|
@@ -140,8 +140,10 @@ module PrD
|
|
|
140
140
|
render_events(document)
|
|
141
141
|
render_summary(document)
|
|
142
142
|
|
|
143
|
+
rendered_pdf = document.render
|
|
144
|
+
rendered_pdf << "\n% Index\n"
|
|
143
145
|
@io.binmode if @io.respond_to?(:binmode)
|
|
144
|
-
@io.write(
|
|
146
|
+
@io.write(rendered_pdf)
|
|
145
147
|
super
|
|
146
148
|
end
|
|
147
149
|
|
|
@@ -225,6 +225,19 @@ module PrD
|
|
|
225
225
|
network(at:, warmup_time:, &block).map(&:url)
|
|
226
226
|
end
|
|
227
227
|
|
|
228
|
+
# return the request when it is finished, or raise Timeout::Error if it doesn't finish within the timeout
|
|
229
|
+
def wait_for_network_url_done(at:, url:, warmup_time: 2, timeout: 20)
|
|
230
|
+
session = prepare_browser_session(at:, warmup_time:)
|
|
231
|
+
yield session if block_given?
|
|
232
|
+
deadline = Time.now + timeout.to_f
|
|
233
|
+
loop do
|
|
234
|
+
request = session.browser.network.traffic.find { |request| request.url == url && request.finished? }
|
|
235
|
+
break request if request
|
|
236
|
+
raise Timeout::Error, "Timeout waiting for network request: #{url}" if Time.now > deadline
|
|
237
|
+
sleep(0.1)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
228
241
|
def pdf(at:, warmup_time: 2)
|
|
229
242
|
session = prepare_browser_session(at:, warmup_time:)
|
|
230
243
|
browser = session.browser
|