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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 86e99563f7268abd22cb2aefb8bf9b932ffc22b54564a99e231c1eb9d0282aee
4
- data.tar.gz: 3a7cf8e9a34e830df9999976bfcc2c0298e76d7aef8494038bdc9ca369318761
3
+ metadata.gz: 0a935022c47a87f28cab73012b6c96563616958a6eb9ff28d982ed7dc6b7abdd
4
+ data.tar.gz: 7b1ad8ea3bc58ce2a167508d95a8e63f574078e4f7b06e007f8650185cfc43fb
5
5
  SHA512:
6
- metadata.gz: cf9e2016027998b4ae11312b17bfc94b55f540acbff985de714d5aff433855b72bbd5845c1bab2ba531dd3c661cb2656d65046dcd7d7807abb09af3b278742fc
7
- data.tar.gz: 87be48c549b49ac867e86e90b7b38a0b83211d40359aa30c59f0eec2ea288884e4879a69346535a722d3d6671d81c15c6fbe1ba03e796951c09a4c7fed261b1b
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
- tests =
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.map { |file| File.read(file) }
128
+ files
113
129
  elsif File.file?(input_path)
114
- [File.read(input_path)]
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, formatter: FORMATTERS.fetch(formatter_types.first)[:klass].new(io: STDOUT, serializers:, mode: mode.to_sym) }]
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:, formatter: config[:klass].new(io:, serializers:, mode: mode.to_sym) }
161
+ { io:, formatter_type: type }
145
162
  end
146
163
  end
147
164
 
148
- formatter =
149
- if destinations.length == 1
150
- destinations.first[:formatter]
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
- CompositeFormatter.new(destinations.map { |destination| destination[:formatter] })
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
- runtime = PrD::Runtime.new(formatter:, output_dir: runtime_output_dir, config_file: options[:config])
157
- runtime.run(tests)
158
- exit_status = runtime.success? ? 0 : 1
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
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
3
  begin
4
+ require 'pr_d/version'
4
5
  require 'pr_d'
5
6
  rescue LoadError
7
+ require_relative '../lib/pr_d/version'
6
8
  require_relative '../lib/pr_d'
7
9
  end
8
10
 
@@ -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: 1rem 0 0.75rem;
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: var(--paper);
259
- border: 1px solid var(--line);
260
- border-radius: 4px;
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.75rem;
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.7rem 0 0.2rem;
319
- padding: 0.55rem 0.7rem;
320
- border-radius: 4px;
321
- border: 1px solid var(--line);
322
- font-weight: 500;
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: var(--pass-bg);
327
- color: var(--pass-fg);
399
+ background: transparent;
400
+ color: var(--muted);
328
401
  }
329
402
 
330
403
  .status.failure {
331
- background: var(--fail-bg);
332
- color: var(--fail-fg);
404
+ background: transparent;
405
+ color: var(--muted);
333
406
  }
334
407
 
335
408
  .status.pending {
336
- background: var(--pending-bg);
337
- color: var(--pending-fg);
409
+ background: transparent;
410
+ color: var(--muted);
338
411
  }
339
412
 
340
413
  .subject-block {
341
- margin: 0.5rem 0 0.75rem;
342
- padding: 0.75rem;
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: #fafafa;
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.5rem 0 0.75rem;
350
- border: 1px solid #d1d5db;
351
- border-radius: 4px;
352
- background: #fff;
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.62rem 0.8rem;
439
+ padding: 0.24rem 0.26rem;
359
440
  display: flex;
360
441
  align-items: center;
361
442
  justify-content: space-between;
362
- gap: 0.8rem;
363
- font-size: 0.9rem;
364
- font-weight: 600;
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.76rem;
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.75rem 0.75rem;
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
- "<li class=\"index-item\" style=\"--index-level: #{entry[:level]};\"><a class=\"index-link\" href=\"##{entry[:anchor_id]}\" title=\"#{label}\">#{label}</a></li>"
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" aria-controls="report-index">Show index</button>
534
- <nav id="report-index" class="report-index" aria-label="Report index">
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.getElementById('report-index');
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
- marker =
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(document.render)
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