probatio_diabolica 0.4.4 → 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: 110c77befc1ae2a7b81ade0e355482bc56ff3a69d1a53dea7a6684a126f84643
4
- data.tar.gz: e68915cc2e7e7f81e73047c8d833011e62fbc44352e8308f70c34f02b516fee4
3
+ metadata.gz: 0a935022c47a87f28cab73012b6c96563616958a6eb9ff28d982ed7dc6b7abdd
4
+ data.tar.gz: 7b1ad8ea3bc58ce2a167508d95a8e63f574078e4f7b06e007f8650185cfc43fb
5
5
  SHA512:
6
- metadata.gz: 1ce65d410be41063de1b4ca71d1cfe9f78396626c869bfda485d6bac4c26f16420e60f0b6fa6e39d4525accb581169271188c3545f2de1fb61f28f5ae3aecdc4
7
- data.tar.gz: ff5ae5a71121442c5f210c50693250480839a47c78229a9b1b7ae91e23bbcb9f209b42d44d4516dd802d67a45cc9b144c2e5f6fc5cc4338ae4844a05f335f57d
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
@@ -19,6 +19,7 @@ FORMATTERS = {
19
19
  SUPPORTED_FORMATTERS = FORMATTERS.keys.freeze
20
20
  SUPPORTED_MODES = %w[verbose synthetic].freeze
21
21
  DEFAULT_REPORT_BASENAME = 'report'.freeze
22
+ DEFAULT_JOBS = 1
22
23
 
23
24
  class CompositeFormatter
24
25
  def initialize(formatters)
@@ -68,6 +69,17 @@ def normalize_mode(raw_mode)
68
69
  mode
69
70
  end
70
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
+
71
83
  def directory_like_path?(path)
72
84
  path.end_with?(File::SEPARATOR) || Dir.exist?(path)
73
85
  end
@@ -98,6 +110,9 @@ OptionParser
98
110
  opts.on('-m', '--mode MODE', 'Specify output mode (verbose, synthetic)') do |mode|
99
111
  options[:mode] = mode
100
112
  end
113
+ opts.on('-j', '--jobs N', 'Specify number of workers (default: 1)') do |jobs|
114
+ options[:jobs] = jobs
115
+ end
101
116
  end
102
117
  .parse!
103
118
 
@@ -106,17 +121,18 @@ raise 'No tests found. Please specify a test file or directory (e.g. prd example
106
121
  raise "Unexpected arguments: #{ARGV.join(' ')}" unless ARGV.empty?
107
122
  raise "Path not found: #{input_path}" unless File.exist?(input_path)
108
123
 
109
- tests =
124
+ test_files =
110
125
  if File.directory?(input_path)
111
126
  files = Dir[File.join(input_path, '**', '*_spec.rb')].sort
112
127
  raise "No spec files found in directory: #{input_path}" if files.empty?
113
- files.map { |file| File.read(file) }
128
+ files
114
129
  elsif File.file?(input_path)
115
- [File.read(input_path)]
130
+ [input_path]
116
131
  end
117
132
 
118
133
  formatter_types = parse_formatter_types(options[:formatters])
119
134
  mode = normalize_mode(options[:mode])
135
+ jobs = normalize_jobs(options[:jobs])
120
136
  out_base = output_base_path(options[:out])
121
137
 
122
138
  if formatter_types.include?('pdf') && out_base.nil?
@@ -169,15 +185,23 @@ collector = PrD::ReportCollector.new(
169
185
  )
170
186
 
171
187
  runtime_output_dir = out_base.nil? ? nil : File.dirname(out_base)
172
- runtime = PrD::Runtime.new(formatter: collector, output_dir: runtime_output_dir, config_file: options[:config])
173
- runtime.run(tests)
174
- 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
175
199
 
176
200
  destinations.each do |destination|
177
201
  io = destination[:io]
178
202
  type = destination[:formatter_type]
179
203
  formatter = FORMATTERS.fetch(type)[:klass].new(io:, serializers:, mode: mode.to_sym)
180
- PrD::ReportRenderer.render(collector.model, formatter)
204
+ PrD::ReportRenderer.render(runner_result.model, formatter)
181
205
  end
182
206
 
183
207
  destinations.each do |destination|
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
 
@@ -13,13 +13,19 @@ module PrD
13
13
  @rouge_formatter = Rouge::Formatters::HTMLLegacy.new(css_class: 'highlight')
14
14
  @pending_expectation = nil
15
15
  @open_let_group_level = nil
16
+ @pending_scope_type = nil
17
+ @scope_stack = []
18
+ @open_context_blocks = 0
16
19
  end
17
20
 
18
21
  def context(message)
19
22
  close_let_group_if_open
20
23
  anchor_id = next_anchor_id('ctx')
21
24
  add_index_entry(type: :context, label: message, level: @level, anchor_id:)
25
+ @content << %(<section class="context-block" style="--ctx-level: #{@level};">)
22
26
  @content << "<h2 class=\"context\" id=\"#{anchor_id}\">#{escape(message)}</h2>"
27
+ @open_context_blocks += 1
28
+ @pending_scope_type = :context
23
29
  end
24
30
 
25
31
  def success_result(message)
@@ -46,6 +52,7 @@ module PrD
46
52
  add_index_entry(type: :test, label: description.to_s, level: @level, anchor_id:)
47
53
  @content << "<article class=\"test-card\" id=\"#{anchor_id}\">"
48
54
  @content << "<h3 class=\"test-title\">#{escape(description)}</h3>"
55
+ @pending_scope_type = :it
49
56
  end
50
57
 
51
58
  def end_it(description = nil, &block)
@@ -129,6 +136,7 @@ module PrD
129
136
 
130
137
  def flush
131
138
  close_let_group_if_open
139
+ close_all_context_blocks
132
140
  @io << document_opening
133
141
  @io << render_index
134
142
  @io << @content
@@ -138,11 +146,16 @@ module PrD
138
146
 
139
147
  def increment_level
140
148
  close_let_group_if_open if @open_let_group_level == @level
149
+ @scope_stack << (@pending_scope_type || :unknown)
150
+ @pending_scope_type = nil
141
151
  super
142
152
  end
143
153
 
144
154
  def decrement_level
145
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?
146
159
  super
147
160
  end
148
161
 
@@ -287,11 +300,18 @@ module PrD
287
300
 
288
301
  .context {
289
302
  font-size: 1.25rem;
290
- margin: 1rem 0 0.75rem;
303
+ margin: 0.45rem 0 0.6rem;
291
304
  padding-bottom: 0.35rem;
292
305
  border-bottom: 1px solid var(--line);
293
306
  }
294
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
+
295
315
  .test-card {
296
316
  background: transparent;
297
317
  border-left: 3px solid #d1d5db;
@@ -605,8 +625,8 @@ module PrD
605
625
  end.join
606
626
 
607
627
  <<~HTML
608
- <button type="button" class="index-toggle" aria-expanded="false" aria-controls="report-index">Show index</button>
609
- <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">
610
630
  <h2 class="index-title">Index</h2>
611
631
  <ul class="index-list">
612
632
  #{index_items}
@@ -615,7 +635,7 @@ module PrD
615
635
  <script>
616
636
  (function() {
617
637
  var body = document.body;
618
- var nav = document.getElementById('report-index');
638
+ var nav = document.querySelector('.report-index');
619
639
  var toggle = document.querySelector('.index-toggle');
620
640
  if (!body || !nav || !toggle) return;
621
641
 
@@ -865,6 +885,13 @@ module PrD
865
885
  @open_let_group_level = nil
866
886
  end
867
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
+
868
895
  def expectation_inline_value(value)
869
896
  return "(#{normalize_text(value.language)} code)" if code_object?(value)
870
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
@@ -6,6 +6,7 @@ module PrD
6
6
  class RunSpecsTool
7
7
  SUPPORTED_FORMATTERS = %w[simple html json pdf].freeze
8
8
  SUPPORTED_MODES = %w[verbose synthetic].freeze
9
+ DEFAULT_JOBS = 1
9
10
  FORMATTER_EXTENSIONS = {
10
11
  'simple' => '.txt',
11
12
  'html' => '.html',
@@ -64,6 +65,7 @@ module PrD
64
65
  mode = normalize_mode(raw_args['mode'] || raw_args[:mode])
65
66
  out = normalize_optional_string(raw_args['out'] || raw_args[:out])
66
67
  config = normalize_optional_string(raw_args['config'] || raw_args[:config])
68
+ jobs = normalize_jobs(raw_args['jobs'] || raw_args[:jobs])
67
69
 
68
70
  if (formatters.length > 1 || formatters.include?('pdf')) && out.nil?
69
71
  raise ArgumentError, 'Using multiple formatters or pdf requires `out`.'
@@ -74,7 +76,8 @@ module PrD
74
76
  formatters: formatters,
75
77
  mode: mode,
76
78
  out: out,
77
- config: config
79
+ config: config,
80
+ jobs: jobs
78
81
  }
79
82
  end
80
83
 
@@ -114,10 +117,22 @@ module PrD
114
117
  mode
115
118
  end
116
119
 
120
+ def normalize_jobs(value)
121
+ return DEFAULT_JOBS if value.nil?
122
+
123
+ jobs = Integer(value)
124
+ raise ArgumentError, '`jobs` must be an integer greater than or equal to 1.' if jobs < 1
125
+
126
+ jobs
127
+ rescue ArgumentError, TypeError
128
+ raise ArgumentError, '`jobs` must be an integer greater than or equal to 1.'
129
+ end
130
+
117
131
  def build_command(args)
118
132
  bin_path = File.expand_path('../../../bin/prd', __dir__)
119
133
  command = ['bundle', 'exec', 'ruby', bin_path, args[:path]]
120
134
  command << '--mode' << args[:mode]
135
+ command << '--jobs' << args[:jobs].to_s
121
136
 
122
137
  args[:formatters].each do |formatter|
123
138
  command << '-t' << formatter
@@ -1,4 +1,5 @@
1
1
  require 'json'
2
+ require 'pr_d/version'
2
3
 
3
4
  module PrD
4
5
  module Mcp
@@ -55,11 +56,17 @@ module PrD
55
56
  },
56
57
  serverInfo: {
57
58
  name: 'probatio-diabolica-mcp',
58
- version: PrD::VERSION
59
+ version: server_version
59
60
  }
60
61
  }
61
62
  end
62
63
 
64
+ def server_version
65
+ return PrD::VERSION if PrD.const_defined?(:VERSION, false)
66
+
67
+ Gem.loaded_specs['probatio_diabolica']&.version&.to_s || '0.0.0'
68
+ end
69
+
63
70
  def tools_list_result
64
71
  {
65
72
  tools: [run_specs_definition]
@@ -88,6 +95,11 @@ module PrD
88
95
  type: 'string',
89
96
  enum: RunSpecsTool::SUPPORTED_MODES,
90
97
  description: 'Optional output mode.'
98
+ },
99
+ jobs: {
100
+ type: 'integer',
101
+ minimum: 1,
102
+ description: 'Optional number of workers used for file-level execution.'
91
103
  }
92
104
  },
93
105
  required: ['path']
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.4"
4
+ VERSION = "0.4.5"
5
5
  end
@@ -0,0 +1,103 @@
1
+ require 'stringio'
2
+
3
+ module PrD
4
+ class WorkerRunner
5
+ RunResult = Struct.new(:model, :passed_count, :failed_count, keyword_init: true)
6
+ WorkerResult = Struct.new(:model, :passed_count, :failed_count, keyword_init: true)
7
+
8
+ def initialize(file_paths:, jobs:, mode:, serializers:, output_dir:, config_file:, subject_display_strategy:, eager_subject_display_strategy:)
9
+ @file_paths = Array(file_paths)
10
+ @jobs = jobs
11
+ @mode = mode
12
+ @serializers = serializers
13
+ @output_dir = output_dir
14
+ @config_file = config_file
15
+ @subject_display_strategy = subject_display_strategy
16
+ @eager_subject_display_strategy = eager_subject_display_strategy
17
+ end
18
+
19
+ def run
20
+ raise ArgumentError, 'No spec files found to execute.' if @file_paths.empty?
21
+
22
+ worker_count = [[@jobs, 1].max, @file_paths.length].min
23
+ results = Array.new(@file_paths.length)
24
+ mutex = Mutex.new
25
+ next_index = 0
26
+ first_error = nil
27
+
28
+ workers = Array.new(worker_count) do
29
+ Thread.new do
30
+ loop do
31
+ index, path = mutex.synchronize do
32
+ break if next_index >= @file_paths.length
33
+
34
+ current_index = next_index
35
+ next_index += 1
36
+ [current_index, @file_paths[current_index]]
37
+ end
38
+ break if index.nil?
39
+
40
+ begin
41
+ results[index] = run_file(path)
42
+ rescue StandardError => e
43
+ mutex.synchronize { first_error ||= e }
44
+ break
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ workers.each(&:join)
51
+ raise first_error unless first_error.nil?
52
+
53
+ merge_results(results)
54
+ end
55
+
56
+ private
57
+
58
+ def run_file(path)
59
+ collector = PrD::ReportCollector.new(
60
+ io: StringIO.new,
61
+ serializers: @serializers,
62
+ mode: @mode,
63
+ subject_display_strategy: @subject_display_strategy,
64
+ eager_subject_display_strategy: @eager_subject_display_strategy
65
+ )
66
+
67
+ runtime = PrD::Runtime.new(formatter: collector, output_dir: @output_dir, config_file: @config_file)
68
+ runtime.run([File.read(path)])
69
+
70
+ WorkerResult.new(
71
+ model: collector.model,
72
+ passed_count: runtime.passed_count,
73
+ failed_count: runtime.failed_count
74
+ )
75
+ end
76
+
77
+ def merge_results(results)
78
+ merged_model = PrD::ReportModel.new
79
+ passed_count = 0
80
+ failed_count = 0
81
+
82
+ results.each do |result|
83
+ passed_count += result.passed_count
84
+ failed_count += result.failed_count
85
+
86
+ result.model.events.each do |event|
87
+ next if event[:name] == :result
88
+
89
+ merged_model.add_event(
90
+ name: event[:name],
91
+ args: event[:args],
92
+ kwargs: event[:kwargs]
93
+ )
94
+ end
95
+ end
96
+
97
+ merged_model.summary = { passed: passed_count, failed: failed_count }
98
+ merged_model.add_event(name: :result, args: [passed_count, failed_count], kwargs: {})
99
+
100
+ RunResult.new(model: merged_model, passed_count: passed_count, failed_count: failed_count)
101
+ end
102
+ end
103
+ end
data/lib/pr_d.rb CHANGED
@@ -77,6 +77,8 @@ module PrD
77
77
  @models_stack = []
78
78
  @hook_scopes = [{ before: [], after: [] }]
79
79
  @subject_definition_stack = [nil]
80
+ @context_depth = 0
81
+ @eager_subject_rendered = false
80
82
  reset_subject_memoization!
81
83
  if config_file
82
84
  if File.exist?(config_file)
@@ -98,6 +100,7 @@ module PrD
98
100
  end
99
101
 
100
102
  def context(description, model: nil, &block)
103
+ @context_depth += 1
101
104
  @formatter.context(description)
102
105
  @formatter.increment_level
103
106
  @models_stack.push(model) if model
@@ -106,12 +109,17 @@ module PrD
106
109
 
107
110
  instance_eval(&block)
108
111
  rescue StandardError => e
109
- report_context_execution_error(e, description:)
112
+ if @context_depth == 1
113
+ raise e
114
+ else
115
+ report_context_execution_error(e, description:)
116
+ end
110
117
  ensure
111
118
  @subject_definition_stack.pop
112
119
  @hook_scopes.pop
113
120
  @models_stack.pop if model
114
121
  @formatter.decrement_level
122
+ @context_depth -= 1 if @context_depth.positive?
115
123
  end
116
124
 
117
125
  def it(description = nil, model = nil, &block)
@@ -119,6 +127,7 @@ module PrD
119
127
  execution_error = nil
120
128
  before_hooks = collect_before_hooks
121
129
  after_hooks = collect_after_hooks
130
+ @current_expectation_results = []
122
131
 
123
132
  begin
124
133
  description ||= infer_example_description(block)
@@ -149,6 +158,7 @@ module PrD
149
158
  @formatter.end_it(description, &block)
150
159
  @formatter.decrement_level
151
160
  @models_stack.pop if model
161
+ @current_expectation_results = nil
152
162
  reset_subject_memoization!
153
163
  clear_recent_let_accesses!
154
164
  end
@@ -169,7 +179,7 @@ module PrD
169
179
  if @verbose
170
180
  rendered_value =
171
181
  if let_error
172
- "Error while evaluating let(:#{name}): #{let_error.class}: #{let_error.message}"
182
+ "Error while evaluating let(:#{name}): #{let_error.class}: #{normalized_error_message(let_error)}"
173
183
  else
174
184
  block_result
175
185
  end
@@ -184,7 +194,7 @@ module PrD
184
194
  instance_variable_set("@#{name}", block_result)
185
195
  define_singleton_method(name) do
186
196
  if let_error
187
- raise let_error.class, let_error.message, let_error.backtrace
197
+ raise let_error.class, normalized_error_message(let_error), let_error.backtrace
188
198
  end
189
199
 
190
200
  record_let_access(name, block_result, callsite: caller_locations(1, 1).first)
@@ -208,30 +218,40 @@ module PrD
208
218
  @formatter.to
209
219
  @formatter.matcher(matcher)
210
220
  result = matcher.matches?(@actual)
211
- return result if result.pass
221
+ final_result = if result.pass
222
+ result
223
+ else
224
+ TestResult.new(
225
+ comment: merge_expectation_comments(
226
+ build_expectation_failure_message(matcher, negated: false),
227
+ result.comment
228
+ ),
229
+ pass: false
230
+ )
231
+ end
212
232
 
213
- TestResult.new(
214
- comment: merge_expectation_comments(
215
- build_expectation_failure_message(matcher, negated: false),
216
- result.comment
217
- ),
218
- pass: false
219
- )
233
+ record_expectation_result(final_result)
234
+ final_result
220
235
  end
221
236
 
222
237
  def not_to(matcher)
223
238
  @formatter.not_to
224
239
  @formatter.matcher(matcher)
225
240
  result = matcher.matches?(@actual)
226
- return TestResult.new(comment: result.comment, pass: true) unless result.pass
241
+ final_result = unless result.pass
242
+ TestResult.new(comment: result.comment, pass: true)
243
+ else
244
+ TestResult.new(
245
+ comment: merge_expectation_comments(
246
+ build_expectation_failure_message(matcher, negated: true),
247
+ result.comment
248
+ ),
249
+ pass: false
250
+ )
251
+ end
227
252
 
228
- TestResult.new(
229
- comment: merge_expectation_comments(
230
- build_expectation_failure_message(matcher, negated: true),
231
- result.comment
232
- ),
233
- pass: false
234
- )
253
+ record_expectation_result(final_result)
254
+ final_result
235
255
  end
236
256
 
237
257
  def subject(&block)
@@ -247,11 +267,15 @@ module PrD
247
267
  raise ArgumentError, 'subject! requires a block.' unless block_given?
248
268
 
249
269
  subject(&block)
250
- if @verbose && @formatter.eager_subject_display_strategy == :on_definition
251
- @formatter.subject(instance_eval(&block))
252
- end
270
+ @eager_subject_rendered = false
253
271
  render_in_before = @verbose && @formatter.eager_subject_display_strategy == :on_evaluation
254
- before { evaluate_subject_value(render: render_in_before) }
272
+ before do
273
+ value = evaluate_subject_value(render: render_in_before)
274
+ if @verbose && @formatter.eager_subject_display_strategy == :on_definition && !@eager_subject_rendered
275
+ @formatter.subject(value)
276
+ @eager_subject_rendered = true
277
+ end
278
+ end
255
279
  nil
256
280
  end
257
281
 
@@ -268,9 +292,9 @@ module PrD
268
292
  @formatter.expect(@actual, label: nil)
269
293
  else
270
294
  @actual = subject
271
- @actual_label = nil
295
+ @actual_label = consume_let_label_for_value(@actual, callsite:)
272
296
  # Avoid to display the subject value twice
273
- @formatter.expect('The subject', label: nil)
297
+ @formatter.expect('The subject', label: @actual_label)
274
298
  end
275
299
  self
276
300
  end
@@ -436,27 +460,40 @@ module PrD
436
460
  end
437
461
 
438
462
  def report_test_execution_error(error)
439
- $stderr.puts "An error occurred while executing test: #{error.message}"
463
+ error_message = normalized_error_message(error)
464
+ $stderr.puts "An error occurred while executing test: #{error_message}"
440
465
  $stderr.puts error.backtrace.join("\n")
441
- @formatter.failure_result("Test failed at #{formatted_time} with error message: #{error.message}")
466
+ @formatter.failure_result("Test failed at #{formatted_time} with error message: #{error_message}")
442
467
  @failed_count += 1
443
468
  end
444
469
 
445
470
  def report_context_execution_error(error, description:)
446
- $stderr.puts "An error occurred while executing context '#{description}': #{error.message}"
471
+ error_message = normalized_error_message(error)
472
+ $stderr.puts "An error occurred while executing context '#{description}': #{error_message}"
447
473
  $stderr.puts error.backtrace.join("\n")
448
- @formatter.failure_result("Context '#{description}' failed at #{formatted_time} with error message: #{error.message}")
474
+ @formatter.failure_result("Context '#{description}' failed at #{formatted_time} with error message: #{error_message}")
449
475
  @failed_count += 1
450
476
  end
451
477
 
452
478
  def report_spec_source_execution_error(error, source_index:)
453
- $stderr.puts "An error occurred while loading spec source ##{source_index}: #{error.message}"
479
+ error_message = normalized_error_message(error)
480
+ $stderr.puts "An error occurred while loading spec source ##{source_index}: #{error_message}"
454
481
  $stderr.puts error.backtrace.join("\n")
455
- @formatter.failure_result("Spec source ##{source_index} failed at #{formatted_time} with error message: #{error.message}")
482
+ @formatter.failure_result("Spec source ##{source_index} failed at #{formatted_time} with error message: #{error_message}")
456
483
  @failed_count += 1
457
484
  end
458
485
 
486
+ def normalized_error_message(error)
487
+ error.message.to_s.gsub(/undefined method '([^']+)'/) { "undefined method `#{$1}'" }
488
+ end
489
+
459
490
  def process_test_result(result)
491
+ expectation_results = @current_expectation_results || []
492
+ unless expectation_results.empty?
493
+ failing_result = expectation_results.find { |expectation_result| !expectation_result.pass }
494
+ result = failing_result || expectation_results.last
495
+ end
496
+
460
497
  unless result.respond_to?(:pass)
461
498
  raise NoMethodError, 'Test example must return a matcher result. Use expect(...).to(...) or expect(...).not_to(...).'
462
499
  end
@@ -518,5 +555,11 @@ module PrD
518
555
  "#{failure_message}. #{matcher_comment}"
519
556
  end
520
557
 
558
+ def record_expectation_result(result)
559
+ return if @current_expectation_results.nil?
560
+
561
+ @current_expectation_results << result
562
+ end
563
+
521
564
  end
522
565
  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.4
4
+ version: 0.4.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Laporte Mathieu
@@ -136,6 +136,7 @@ files:
136
136
  - lib/pr_d/report_model.rb
137
137
  - lib/pr_d/report_renderer.rb
138
138
  - lib/pr_d/version.rb
139
+ - lib/pr_d/worker_runner.rb
139
140
  - lib/probatio_diabolica.rb
140
141
  - probatio_diabolica.gemspec
141
142
  homepage: https://github.com/mathieulaporte/probatio_diabolica