probatio_diabolica 0.4.3 → 0.4.4

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: 110c77befc1ae2a7b81ade0e355482bc56ff3a69d1a53dea7a6684a126f84643
4
+ data.tar.gz: e68915cc2e7e7f81e73047c8d833011e62fbc44352e8308f70c34f02b516fee4
5
5
  SHA512:
6
- metadata.gz: cf9e2016027998b4ae11312b17bfc94b55f540acbff985de714d5aff433855b72bbd5845c1bab2ba531dd3c661cb2656d65046dcd7d7807abb09af3b278742fc
7
- data.tar.gz: 87be48c549b49ac867e86e90b7b38a0b83211d40359aa30c59f0eec2ea288884e4879a69346535a722d3d6671d81c15c6fbe1ba03e796951c09a4c7fed261b1b
6
+ metadata.gz: 1ce65d410be41063de1b4ca71d1cfe9f78396626c869bfda485d6bac4c26f16420e60f0b6fa6e39d4525accb581169271188c3545f2de1fb61f28f5ae3aecdc4
7
+ data.tar.gz: ff5ae5a71121442c5f210c50693250480839a47c78229a9b1b7ae91e23bbcb9f209b42d44d4516dd802d67a45cc9b144c2e5f6fc5cc4338ae4844a05f335f57d
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 },
@@ -133,7 +134,7 @@ serializers = {
133
134
 
134
135
  destinations =
135
136
  if out_base.nil?
136
- [{ io: STDOUT, formatter: FORMATTERS.fetch(formatter_types.first)[:klass].new(io: STDOUT, serializers:, mode: mode.to_sym) }]
137
+ [{ io: STDOUT, formatter_type: formatter_types.first }]
137
138
  else
138
139
  output_dir = File.dirname(out_base)
139
140
  FileUtils.mkdir_p(output_dir)
@@ -141,21 +142,44 @@ destinations =
141
142
  config = FORMATTERS.fetch(type)
142
143
  output_path = "#{out_base}#{config[:extension]}"
143
144
  io = File.open(output_path, config[:binary] ? 'wb' : 'w')
144
- { io:, formatter: config[:klass].new(io:, serializers:, mode: mode.to_sym) }
145
+ { io:, formatter_type: type }
145
146
  end
146
147
  end
147
148
 
148
- formatter =
149
- if destinations.length == 1
150
- destinations.first[:formatter]
149
+ subject_display_strategy =
150
+ if formatter_types.length == 1
151
+ FORMATTERS.fetch(formatter_types.first)[:klass].new(io: StringIO.new, serializers:, mode: mode.to_sym).subject_display_strategy
151
152
  else
152
- CompositeFormatter.new(destinations.map { |destination| destination[:formatter] })
153
+ nil
153
154
  end
154
155
 
156
+ eager_subject_display_strategy =
157
+ if formatter_types.length == 1
158
+ FORMATTERS.fetch(formatter_types.first)[:klass].new(io: StringIO.new, serializers:, mode: mode.to_sym).eager_subject_display_strategy
159
+ else
160
+ nil
161
+ end
162
+
163
+ collector = PrD::ReportCollector.new(
164
+ io: StringIO.new,
165
+ serializers: serializers,
166
+ mode: mode.to_sym,
167
+ subject_display_strategy: subject_display_strategy,
168
+ eager_subject_display_strategy: eager_subject_display_strategy
169
+ )
170
+
155
171
  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])
172
+ runtime = PrD::Runtime.new(formatter: collector, output_dir: runtime_output_dir, config_file: options[:config])
157
173
  runtime.run(tests)
158
174
  exit_status = runtime.success? ? 0 : 1
175
+
176
+ destinations.each do |destination|
177
+ io = destination[:io]
178
+ type = destination[:formatter_type]
179
+ formatter = FORMATTERS.fetch(type)[:klass].new(io:, serializers:, mode: mode.to_sym)
180
+ PrD::ReportRenderer.render(collector.model, formatter)
181
+ end
182
+
159
183
  destinations.each do |destination|
160
184
  io = destination[:io]
161
185
  io.close if io != STDOUT
@@ -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,9 +12,11 @@ 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
15
16
  end
16
17
 
17
18
  def context(message)
19
+ close_let_group_if_open
18
20
  anchor_id = next_anchor_id('ctx')
19
21
  add_index_entry(type: :context, label: message, level: @level, anchor_id:)
20
22
  @content << "<h2 class=\"context\" id=\"#{anchor_id}\">#{escape(message)}</h2>"
@@ -37,6 +39,7 @@ module PrD
37
39
  end
38
40
 
39
41
  def it(description = nil, &block)
42
+ close_let_group_if_open
40
43
  @current_test_title = description.to_s
41
44
  @pending_expectation = nil
42
45
  anchor_id = next_anchor_id('test')
@@ -56,6 +59,7 @@ module PrD
56
59
 
57
60
  def let(name_or_value, value = MISSING_VALUE)
58
61
  return if synthetic?
62
+ start_let_group_if_needed
59
63
  name, rendered_value = named_value_arguments(name_or_value, value)
60
64
  label = name.nil? ? 'Let' : "Let(:#{name})"
61
65
  render_collapsible_let_block(label, rendered_value)
@@ -63,6 +67,7 @@ module PrD
63
67
 
64
68
  def subject(subject)
65
69
  return if synthetic?
70
+ close_let_group_if_open
66
71
  render_value_block('Subject', subject)
67
72
  end
68
73
 
@@ -75,6 +80,7 @@ module PrD
75
80
  end
76
81
 
77
82
  def pending(description = nil)
83
+ close_let_group_if_open
78
84
  pending_label = description || 'Pending test'
79
85
  anchor_id = next_anchor_id('pending')
80
86
  add_index_entry(type: :pending, label: pending_label, level: @level, anchor_id:)
@@ -122,6 +128,7 @@ module PrD
122
128
  end
123
129
 
124
130
  def flush
131
+ close_let_group_if_open
125
132
  @io << document_opening
126
133
  @io << render_index
127
134
  @io << @content
@@ -129,6 +136,16 @@ module PrD
129
136
  super
130
137
  end
131
138
 
139
+ def increment_level
140
+ close_let_group_if_open if @open_let_group_level == @level
141
+ super
142
+ end
143
+
144
+ def decrement_level
145
+ close_let_group_if_open if !@open_let_group_level.nil? && @open_let_group_level >= @level
146
+ super
147
+ end
148
+
132
149
  private
133
150
 
134
151
  def document_opening
@@ -168,7 +185,6 @@ module PrD
168
185
  margin: 1rem auto 2rem;
169
186
  padding: 1rem;
170
187
  background: var(--paper);
171
- border: 1px solid var(--line);
172
188
  border-radius: 4px;
173
189
  }
174
190
 
@@ -205,7 +221,6 @@ module PrD
205
221
  z-index: 1000;
206
222
  width: var(--sidebar-width);
207
223
  background: var(--paper);
208
- border: 1px solid var(--line);
209
224
  border-radius: 4px;
210
225
  padding: 0.9rem 1rem;
211
226
  overflow-y: auto;
@@ -234,8 +249,31 @@ module PrD
234
249
  padding-left: calc(var(--index-level, 0) * 1rem);
235
250
  }
236
251
 
252
+ .index-row {
253
+ display: flex;
254
+ align-items: center;
255
+ gap: 0.3rem;
256
+ min-width: 0;
257
+ }
258
+
259
+ .context-toggle {
260
+ border: 1px solid var(--line);
261
+ border-radius: 3px;
262
+ background: #fff;
263
+ color: var(--accent);
264
+ cursor: pointer;
265
+ font-size: 0.72rem;
266
+ width: 1.2rem;
267
+ height: 1.2rem;
268
+ line-height: 1;
269
+ padding: 0;
270
+ flex: 0 0 auto;
271
+ }
272
+
237
273
  .index-link {
238
274
  display: block;
275
+ flex: 1 1 auto;
276
+ min-width: 0;
239
277
  white-space: nowrap;
240
278
  overflow: hidden;
241
279
  text-overflow: ellipsis;
@@ -255,15 +293,26 @@ module PrD
255
293
  }
256
294
 
257
295
  .test-card {
258
- background: var(--paper);
259
- border: 1px solid var(--line);
260
- border-radius: 4px;
261
- padding: 0.9rem 1rem;
296
+ background: transparent;
297
+ border-left: 3px solid #d1d5db;
298
+ padding: 0.8rem 0.95rem;
262
299
  margin: 0 0 1rem;
263
300
  }
264
301
 
302
+ .test-card:has(.status.success) {
303
+ border-left-color: #16a34a;
304
+ }
305
+
306
+ .test-card:has(.status.failure) {
307
+ border-left-color: #dc2626;
308
+ }
309
+
310
+ .test-card:has(.status.pending) {
311
+ border-left-color: #d97706;
312
+ }
313
+
265
314
  .test-title {
266
- margin: 0 0 0.75rem;
315
+ margin: 0 0 0.5rem;
267
316
  font-size: 1.1rem;
268
317
  }
269
318
 
@@ -315,53 +364,65 @@ module PrD
315
364
  }
316
365
 
317
366
  .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;
367
+ margin: 0.22rem 0 0;
368
+ padding: 0;
369
+ border: 0;
370
+ border-radius: 0;
371
+ font-weight: 400;
372
+ font-size: 0.76rem;
373
+ letter-spacing: 0.01em;
374
+ color: var(--muted);
375
+ opacity: 0.8;
323
376
  }
324
377
 
325
378
  .status.success {
326
- background: var(--pass-bg);
327
- color: var(--pass-fg);
379
+ background: transparent;
380
+ color: var(--muted);
328
381
  }
329
382
 
330
383
  .status.failure {
331
- background: var(--fail-bg);
332
- color: var(--fail-fg);
384
+ background: transparent;
385
+ color: var(--muted);
333
386
  }
334
387
 
335
388
  .status.pending {
336
- background: var(--pending-bg);
337
- color: var(--pending-fg);
389
+ background: transparent;
390
+ color: var(--muted);
338
391
  }
339
392
 
340
393
  .subject-block {
341
- margin: 0.5rem 0 0.75rem;
342
- padding: 0.75rem;
394
+ margin: 0.18rem 0 0.34rem;
395
+ padding: 0.36rem 0.5rem;
343
396
  border: 1px solid #d1d5db;
344
397
  border-radius: 4px;
345
- background: #fafafa;
398
+ background: #fcfcfc;
399
+ }
400
+
401
+ .lets-group {
402
+ margin: 0.2rem 0 0.6rem;
403
+ padding: 0.18rem 0.38rem;
404
+ background: #fcfcfc;
405
+ border-left: 2px solid #e5e7eb;
406
+ border-radius: 3px;
346
407
  }
347
408
 
348
409
  .let-block {
349
- margin: 0.5rem 0 0.75rem;
350
- border: 1px solid #d1d5db;
351
- border-radius: 4px;
352
- background: #fff;
410
+ margin: 0.15rem 0;
411
+ border: 0;
412
+ border-radius: 0;
413
+ background: transparent;
353
414
  }
354
415
 
355
416
  .let-toggle {
356
417
  list-style: none;
357
418
  cursor: pointer;
358
- padding: 0.62rem 0.8rem;
419
+ padding: 0.24rem 0.26rem;
359
420
  display: flex;
360
421
  align-items: center;
361
422
  justify-content: space-between;
362
- gap: 0.8rem;
363
- font-size: 0.9rem;
364
- font-weight: 600;
423
+ gap: 0.4rem;
424
+ font-size: 0.82rem;
425
+ font-weight: 500;
365
426
  color: #0f172a;
366
427
  }
367
428
 
@@ -372,7 +433,7 @@ module PrD
372
433
  .let-toggle::after {
373
434
  content: 'Open';
374
435
  color: var(--muted);
375
- font-size: 0.76rem;
436
+ font-size: 0.68rem;
376
437
  letter-spacing: 0.03em;
377
438
  text-transform: uppercase;
378
439
  }
@@ -382,7 +443,7 @@ module PrD
382
443
  }
383
444
 
384
445
  .let-content {
385
- padding: 0 0.75rem 0.75rem;
446
+ padding: 0 0.28rem 0.12rem;
386
447
  }
387
448
 
388
449
  .let-content .subject-block {
@@ -526,7 +587,21 @@ module PrD
526
587
 
527
588
  index_items = @index_entries.map do |entry|
528
589
  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>"
590
+ type = entry[:type].to_s
591
+ toggle =
592
+ if entry[:type] == :context
593
+ '<button type="button" class="context-toggle" aria-expanded="false" title="Unfold context">+</button>'
594
+ else
595
+ ''
596
+ end
597
+ <<~HTML.chomp
598
+ <li class="index-item" style="--index-level: #{entry[:level]};" data-level="#{entry[:level]}" data-type="#{type}">
599
+ <div class="index-row">
600
+ #{toggle}
601
+ <a class="index-link" href="##{entry[:anchor_id]}" title="#{label}">#{label}</a>
602
+ </div>
603
+ </li>
604
+ HTML
530
605
  end.join
531
606
 
532
607
  <<~HTML
@@ -557,6 +632,49 @@ module PrD
557
632
  syncToggleLabel();
558
633
  });
559
634
 
635
+ var items = Array.prototype.slice.call(nav.querySelectorAll('.index-item'));
636
+ var contextButtons = Array.prototype.slice.call(nav.querySelectorAll('.context-toggle'));
637
+
638
+ var levelOf = function(item) {
639
+ return Number(item.getAttribute('data-level') || '0');
640
+ };
641
+
642
+ var descendantsOf = function(parentItem) {
643
+ var start = items.indexOf(parentItem);
644
+ if (start < 0) return [];
645
+ var parentLevel = levelOf(parentItem);
646
+ var descendants = [];
647
+
648
+ for (var i = start + 1; i < items.length; i += 1) {
649
+ var candidate = items[i];
650
+ if (levelOf(candidate) <= parentLevel) break;
651
+ descendants.push(candidate);
652
+ }
653
+ return descendants;
654
+ };
655
+
656
+ contextButtons.forEach(function(button) {
657
+ var contextItem = button.closest('.index-item');
658
+ if (!contextItem) return;
659
+
660
+ button.addEventListener('click', function(event) {
661
+ event.preventDefault();
662
+ var expanded = button.getAttribute('aria-expanded') !== 'false';
663
+ var nextExpanded = !expanded;
664
+ button.setAttribute('aria-expanded', String(nextExpanded));
665
+ button.textContent = nextExpanded ? '-' : '+';
666
+ button.title = nextExpanded ? 'Fold context' : 'Unfold context';
667
+
668
+ descendantsOf(contextItem).forEach(function(item) {
669
+ item.style.display = nextExpanded ? '' : 'none';
670
+ });
671
+ });
672
+
673
+ descendantsOf(contextItem).forEach(function(item) {
674
+ item.style.display = 'none';
675
+ });
676
+ });
677
+
560
678
  syncToggleLabel();
561
679
  })();
562
680
  </script>
@@ -564,13 +682,7 @@ module PrD
564
682
  end
565
683
 
566
684
  def index_label(entry)
567
- marker =
568
- case entry[:type]
569
- when :context then '+'
570
- else '-'
571
- end
572
-
573
- "#{marker} #{entry[:label]}"
685
+ "#{entry[:label]}"
574
686
  end
575
687
 
576
688
  def add_index_entry(type:, label:, level:, anchor_id:)
@@ -738,6 +850,21 @@ module PrD
738
850
  render_expectation_code_block('Expected', expected_value) if expected_present
739
851
  end
740
852
 
853
+ def start_let_group_if_needed
854
+ return if @open_let_group_level == @level
855
+
856
+ close_let_group_if_open
857
+ @content << '<section class="lets-group">'
858
+ @open_let_group_level = @level
859
+ end
860
+
861
+ def close_let_group_if_open
862
+ return if @open_let_group_level.nil?
863
+
864
+ @content << '</section>'
865
+ @open_let_group_level = nil
866
+ end
867
+
741
868
  def expectation_inline_value(value)
742
869
  return "(#{normalize_text(value.language)} code)" if code_object?(value)
743
870
 
@@ -0,0 +1,132 @@
1
+ module PrD
2
+ class ReportCollector < PrD::Formatters::Formatter
3
+ def initialize(io: $stdout, serializers: {}, mode: :verbose, display_adapters: {}, subject_display_strategy: :on_evaluation, eager_subject_display_strategy: :on_evaluation)
4
+ super(io: io, serializers: serializers, mode: mode, display_adapters: display_adapters)
5
+ @model = PrD::ReportModel.new
6
+ @subject_display_strategy = subject_display_strategy
7
+ @eager_subject_display_strategy = eager_subject_display_strategy
8
+ end
9
+
10
+ attr_reader :model
11
+
12
+ def title(message)
13
+ record(:title, snapshot(message))
14
+ end
15
+
16
+ def context(message)
17
+ record(:context, snapshot(message))
18
+ end
19
+
20
+ def success_result(message)
21
+ record(:success_result, snapshot(message))
22
+ end
23
+
24
+ def failure_result(message)
25
+ record(:failure_result, snapshot(message))
26
+ end
27
+
28
+ def it(description = nil, &block)
29
+ @current_test_title = description.to_s
30
+ record(:it, snapshot(description))
31
+ end
32
+
33
+ def end_it(description = nil, &block)
34
+ record(:end_it, snapshot(description))
35
+ end
36
+
37
+ def justification(justification)
38
+ record(:justification, snapshot(justification))
39
+ end
40
+
41
+ def let(name_or_value, value = MISSING_VALUE)
42
+ if value.equal?(MISSING_VALUE)
43
+ record(:let, snapshot(name_or_value))
44
+ else
45
+ record(:let, snapshot(name_or_value), snapshot(value))
46
+ end
47
+ end
48
+
49
+ def subject(subject)
50
+ record(:subject, snapshot(subject))
51
+ end
52
+
53
+ def subject_display_strategy
54
+ @subject_display_strategy
55
+ end
56
+
57
+ def eager_subject_display_strategy
58
+ @eager_subject_display_strategy
59
+ end
60
+
61
+ def pending(description = nil)
62
+ record(:pending, snapshot(description))
63
+ end
64
+
65
+ def expect(expectation, label: nil)
66
+ record(:expect, snapshot(expectation), label: snapshot(label))
67
+ end
68
+
69
+ def to
70
+ record(:to)
71
+ end
72
+
73
+ def not_to
74
+ record(:not_to)
75
+ end
76
+
77
+ def matcher(matcher, sources: nil)
78
+ record(:matcher, snapshot_matcher(matcher), sources: snapshot(sources))
79
+ end
80
+
81
+ def result(passed_count, failed_count)
82
+ @model.summary = { passed: passed_count, failed: failed_count }
83
+ record(:result, snapshot(passed_count), snapshot(failed_count))
84
+ end
85
+
86
+ def increment_level
87
+ super
88
+ record(:increment_level)
89
+ end
90
+
91
+ def decrement_level
92
+ super
93
+ record(:decrement_level)
94
+ end
95
+
96
+ def flush
97
+ # Rendering happens later from the canonical model.
98
+ @io.flush if @io.respond_to?(:flush)
99
+ end
100
+
101
+ private
102
+
103
+ def record(name, *args, **kwargs)
104
+ @model.add_event(name:, args:, kwargs:)
105
+ end
106
+
107
+ def snapshot(value)
108
+ @model.snapshot(value)
109
+ end
110
+
111
+ def snapshot_matcher(matcher)
112
+ clone =
113
+ begin
114
+ matcher.dup
115
+ rescue StandardError
116
+ matcher
117
+ end
118
+
119
+ if clone.instance_variable_defined?(:@expected)
120
+ clone.instance_variable_set(:@expected, snapshot(clone.instance_variable_get(:@expected)))
121
+ end
122
+
123
+ if clone.respond_to?(:expected_label) && clone.respond_to?(:expected_label=)
124
+ clone.expected_label = snapshot(clone.expected_label)
125
+ end
126
+
127
+ clone
128
+ rescue StandardError
129
+ matcher
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,181 @@
1
+ require 'stringio'
2
+ require 'tempfile'
3
+
4
+ module PrD
5
+ class ReportModel
6
+ FerrumNodeSnapshot = Struct.new(:payload, :summary, keyword_init: true)
7
+
8
+ attr_reader :events
9
+ attr_accessor :summary
10
+
11
+ def initialize
12
+ @events = []
13
+ @summary = { passed: 0, failed: 0 }
14
+ end
15
+
16
+ def add_event(name:, args:, kwargs:)
17
+ @events << { name:, args:, kwargs: }
18
+ end
19
+
20
+ def snapshot(value)
21
+ return value if value.nil?
22
+ return value if immutable_scalar?(value)
23
+
24
+ if code_object?(value)
25
+ return PrD::Code.new(source: value.source.to_s.dup, language: value.language.to_s.dup)
26
+ end
27
+
28
+ if ferrum_node?(value)
29
+ return FerrumNodeSnapshot.new(
30
+ payload: ferrum_node_payload_snapshot(value),
31
+ summary: ferrum_node_summary_snapshot(value)
32
+ )
33
+ end
34
+
35
+ if file_like?(value)
36
+ return snapshot_file(value)
37
+ end
38
+
39
+ if pdf_reader?(value)
40
+ return snapshot_pdf_reader(value)
41
+ end
42
+
43
+ if value.is_a?(Array)
44
+ return value.map { |entry| snapshot(entry) }
45
+ end
46
+
47
+ if value.is_a?(Hash)
48
+ return value.each_with_object({}) { |(key, entry), acc| acc[snapshot(key)] = snapshot(entry) }
49
+ end
50
+
51
+ begin
52
+ value.dup
53
+ rescue StandardError
54
+ value
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def immutable_scalar?(value)
61
+ value.is_a?(Numeric) || value.is_a?(Symbol) || value.equal?(true) || value.equal?(false)
62
+ end
63
+
64
+ def code_object?(value)
65
+ defined?(PrD::Code) && value.is_a?(PrD::Code)
66
+ end
67
+
68
+ def ferrum_node?(value)
69
+ value.respond_to?(:class) && value.class.respond_to?(:name) && value.class.name == 'Ferrum::Node'
70
+ rescue StandardError
71
+ false
72
+ end
73
+
74
+ def ferrum_node_payload_snapshot(node)
75
+ return nil unless node.respond_to?(:evaluate)
76
+
77
+ raw_payload = node.evaluate(<<~JS)
78
+ (() => {
79
+ const element = this;
80
+ if (!element) return null;
81
+
82
+ const rawClassName = element.className;
83
+ const className =
84
+ typeof rawClassName === "string" ? rawClassName :
85
+ (rawClassName && typeof rawClassName.baseVal === "string" ? rawClassName.baseVal : "");
86
+
87
+ const classes = className
88
+ .split(/\\s+/)
89
+ .map((token) => token.trim())
90
+ .filter((token) => token.length > 0);
91
+
92
+ const textValue = (element.innerText || element.textContent || "").replace(/\\s+/g, " ").trim();
93
+ const htmlValue = element.outerHTML || "";
94
+
95
+ return {
96
+ tag: element.tagName ? element.tagName.toLowerCase() : null,
97
+ id: element.id || null,
98
+ classes,
99
+ text: textValue.length > 160 ? `${textValue.slice(0, 157)}...` : textValue,
100
+ html: htmlValue.length > 220 ? `${htmlValue.slice(0, 217)}...` : htmlValue
101
+ };
102
+ })()
103
+ JS
104
+ return nil unless raw_payload.is_a?(Hash)
105
+
106
+ {
107
+ tag: raw_payload['tag'] || raw_payload[:tag],
108
+ id: raw_payload['id'] || raw_payload[:id],
109
+ classes: raw_payload['classes'] || raw_payload[:classes],
110
+ text: raw_payload['text'] || raw_payload[:text],
111
+ html: raw_payload['html'] || raw_payload[:html]
112
+ }
113
+ rescue StandardError
114
+ {
115
+ tag: safe_node_call(node, :tag_name),
116
+ text: safe_node_call(node, :text),
117
+ description: safe_node_call(node, :description)
118
+ }
119
+ end
120
+
121
+ def ferrum_node_summary_snapshot(node)
122
+ return nil unless node.respond_to?(:evaluate)
123
+
124
+ ferrum_node_payload_snapshot(node)
125
+ rescue StandardError
126
+ nil
127
+ end
128
+
129
+ def safe_node_call(node, method_name)
130
+ return nil unless node.respond_to?(method_name)
131
+
132
+ node.public_send(method_name)
133
+ rescue StandardError
134
+ nil
135
+ end
136
+
137
+ def file_like?(value)
138
+ value.is_a?(File)
139
+ end
140
+
141
+ def snapshot_file(file)
142
+ file.rewind if file.respond_to?(:rewind)
143
+ bytes = file.read
144
+ file.rewind if file.respond_to?(:rewind)
145
+ ext = File.extname(file.path.to_s)
146
+ temp = Tempfile.new(['prd_snapshot_', ext])
147
+ temp.binmode
148
+ temp.write(bytes || ''.b)
149
+ temp.flush
150
+ temp.rewind
151
+ temp
152
+ rescue StandardError
153
+ file
154
+ end
155
+
156
+ def pdf_reader?(value)
157
+ defined?(PDF::Reader) && value.is_a?(PDF::Reader)
158
+ end
159
+
160
+ def snapshot_pdf_reader(reader)
161
+ return reader unless defined?(PDF::Reader)
162
+
163
+ objects = reader.instance_variable_get(:@objects)
164
+ io = objects&.instance_variable_get(:@io)
165
+ bytes =
166
+ if io.respond_to?(:string)
167
+ io.string
168
+ elsif io.respond_to?(:read)
169
+ current_pos = io.pos if io.respond_to?(:pos)
170
+ content = io.read
171
+ io.seek(current_pos) if io.respond_to?(:seek) && !current_pos.nil?
172
+ content
173
+ end
174
+ return reader if bytes.nil?
175
+
176
+ PDF::Reader.new(StringIO.new(bytes))
177
+ rescue StandardError
178
+ reader
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,20 @@
1
+ module PrD
2
+ module ReportRenderer
3
+ module_function
4
+
5
+ def render(model, formatter)
6
+ model.events.each do |event|
7
+ name = event.fetch(:name)
8
+ args = event.fetch(:args)
9
+ kwargs = event.fetch(:kwargs)
10
+ if kwargs.empty?
11
+ formatter.public_send(name, *args)
12
+ else
13
+ formatter.public_send(name, *args, **kwargs)
14
+ end
15
+ end
16
+ formatter.flush
17
+ formatter
18
+ end
19
+ end
20
+ end
data/lib/pr_d/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PrD
4
- VERSION = "0.4.3"
4
+ VERSION = "0.4.4"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: probatio_diabolica
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.3
4
+ version: 0.4.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Laporte Mathieu
@@ -132,6 +132,9 @@ files:
132
132
  - lib/pr_d/matchers/matcher.rb
133
133
  - lib/pr_d/mcp/run_specs_tool.rb
134
134
  - lib/pr_d/mcp/server.rb
135
+ - lib/pr_d/report_collector.rb
136
+ - lib/pr_d/report_model.rb
137
+ - lib/pr_d/report_renderer.rb
135
138
  - lib/pr_d/version.rb
136
139
  - lib/probatio_diabolica.rb
137
140
  - probatio_diabolica.gemspec