mutation_tester 1.5.1 → 1.6.0
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/CHANGELOG.md +8 -0
- data/Gemfile.lock +1 -1
- data/docs/ci.md +31 -0
- data/docs/execution-runners.md +23 -0
- data/docs/json-schema.md +44 -0
- data/examples/github_actions/redundant_tests.yml +77 -0
- data/exe/mutation_test +11 -1
- data/lib/mutation_tester/batch_runner.rb +3 -2
- data/lib/mutation_tester/configuration.rb +3 -1
- data/lib/mutation_tester/core.rb +37 -9
- data/lib/mutation_tester/fork_runner/worker.rb +3 -2
- data/lib/mutation_tester/fork_runner.rb +2 -2
- data/lib/mutation_tester/minitest_fail_fast.rb +3 -5
- data/lib/mutation_tester/minitest_load_hook.rb +35 -0
- data/lib/mutation_tester/mutation_runner.rb +50 -37
- data/lib/mutation_tester/progress_display.rb +134 -30
- data/lib/mutation_tester/reporters/base_reporter.rb +2 -1
- data/lib/mutation_tester/reporters/batch_json_reporter.rb +2 -1
- data/lib/mutation_tester/reporters/console_reporter.rb +1 -0
- data/lib/mutation_tester/reporters/json_reporter.rb +7 -0
- data/lib/mutation_tester/test_command.rb +60 -31
- data/lib/mutation_tester/test_recorder/minitest_hook.rb +55 -0
- data/lib/mutation_tester/test_recorder/rspec_hook.rb +62 -0
- data/lib/mutation_tester/test_recorder.rb +75 -0
- data/lib/mutation_tester/version.rb +1 -1
- data/lib/mutation_tester.rb +1 -0
- data/readme.md +87 -1
- metadata +7 -2
|
@@ -86,7 +86,7 @@ module MutationTester
|
|
|
86
86
|
end
|
|
87
87
|
|
|
88
88
|
result = run_single_mutation(mutation, :in_memory)
|
|
89
|
-
progress_callback.call(mutation, index + 1) if progress_callback
|
|
89
|
+
progress_callback.call(mutation, index + 1, result) if progress_callback
|
|
90
90
|
results << result
|
|
91
91
|
break if stop_early?(result)
|
|
92
92
|
end
|
|
@@ -113,20 +113,10 @@ module MutationTester
|
|
|
113
113
|
|
|
114
114
|
warn "[MutationTester] In-memory execution selected (parallel, #{pool.size} preloaded workers, zero file writes per mutant)."
|
|
115
115
|
announce_load_time_routing(mutations)
|
|
116
|
-
total = mutations.size
|
|
117
|
-
completed_count = 0
|
|
118
116
|
collected = []
|
|
119
117
|
project_root = discoverable_project_root
|
|
120
118
|
reserve_fallback_shadow_root
|
|
121
|
-
|
|
122
|
-
report_progress = lambda do |_item, _index, result|
|
|
123
|
-
completed_count += 1
|
|
124
|
-
collected << result
|
|
125
|
-
if progress_callback && (completed_count.even? || completed_count == total)
|
|
126
|
-
progress_callback.call(nil, completed_count)
|
|
127
|
-
end
|
|
128
|
-
raise Parallel::Break if stop_early?(result)
|
|
129
|
-
end
|
|
119
|
+
report_progress = parallel_progress_reporter(collected, progress_callback)
|
|
130
120
|
|
|
131
121
|
mapped = with_parallel_interrupt_silenced do
|
|
132
122
|
Parallel.map(mutations, in_processes: pool.size, finish: report_progress) do |mutation|
|
|
@@ -141,21 +131,11 @@ module MutationTester
|
|
|
141
131
|
end
|
|
142
132
|
|
|
143
133
|
def run_in_shadow_parallel(mutations, &progress_callback)
|
|
144
|
-
total = mutations.size
|
|
145
|
-
completed_count = 0
|
|
146
134
|
collected = []
|
|
147
135
|
project_root = find_project_root
|
|
148
136
|
shadow_run_root
|
|
149
|
-
prepare_worker_preloads(
|
|
150
|
-
|
|
151
|
-
report_progress = lambda do |_item, _index, result|
|
|
152
|
-
completed_count += 1
|
|
153
|
-
collected << result
|
|
154
|
-
if progress_callback && (completed_count.even? || completed_count == total)
|
|
155
|
-
progress_callback.call(nil, completed_count)
|
|
156
|
-
end
|
|
157
|
-
raise Parallel::Break if stop_early?(result)
|
|
158
|
-
end
|
|
137
|
+
prepare_worker_preloads(mutations.size)
|
|
138
|
+
report_progress = parallel_progress_reporter(collected, progress_callback)
|
|
159
139
|
|
|
160
140
|
mapped = with_parallel_interrupt_silenced do
|
|
161
141
|
Parallel.map(mutations, in_processes: @config.parallel_processes, finish: report_progress) do |mutation|
|
|
@@ -180,7 +160,7 @@ module MutationTester
|
|
|
180
160
|
results = []
|
|
181
161
|
mutations.each_with_index do |mutation, index|
|
|
182
162
|
result = run_single_mutation(mutation, :shadow, project_root)
|
|
183
|
-
progress_callback.call(mutation, index + 1) if progress_callback
|
|
163
|
+
progress_callback.call(mutation, index + 1, result) if progress_callback
|
|
184
164
|
results << result
|
|
185
165
|
break if stop_early?(result)
|
|
186
166
|
end
|
|
@@ -194,7 +174,7 @@ module MutationTester
|
|
|
194
174
|
results = []
|
|
195
175
|
mutations.each_with_index do |mutation, index|
|
|
196
176
|
result = run_single_mutation(mutation, :in_place)
|
|
197
|
-
progress_callback.call(mutation, index + 1) if progress_callback
|
|
177
|
+
progress_callback.call(mutation, index + 1, result) if progress_callback
|
|
198
178
|
results << result
|
|
199
179
|
break if stop_early?(result)
|
|
200
180
|
end
|
|
@@ -218,6 +198,7 @@ module MutationTester
|
|
|
218
198
|
status: :survived,
|
|
219
199
|
description: mutation[:description]
|
|
220
200
|
}.tap do |result|
|
|
201
|
+
result[:killed_by] = [] if @config.kill_matrix
|
|
221
202
|
if unparseable?(mutation[:code])
|
|
222
203
|
mark_stillborn(result)
|
|
223
204
|
elsif strategy == :in_memory && mutation[:in_memory_safe] == false
|
|
@@ -370,17 +351,20 @@ module MutationTester
|
|
|
370
351
|
runner = active_in_memory_runner
|
|
371
352
|
return in_memory_worker_fallback(mutation, result, project_root, 'the in-memory worker is unavailable') unless runner&.ready?
|
|
372
353
|
|
|
373
|
-
outcome =
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
354
|
+
outcome, tests = recording_failed_tests do |recorder_env|
|
|
355
|
+
runner.execute_in_memory(
|
|
356
|
+
source: mutation[:code],
|
|
357
|
+
path: @source_file,
|
|
358
|
+
timeout: @config.effective_timeout,
|
|
359
|
+
chdir: Dir.pwd,
|
|
360
|
+
env: recorder_env
|
|
361
|
+
)
|
|
362
|
+
end
|
|
379
363
|
|
|
380
364
|
if outcome.status == 'error'
|
|
381
365
|
in_memory_apply_fallback(mutation, result, project_root, outcome.message || 'in-memory application failed')
|
|
382
366
|
else
|
|
383
|
-
apply_outcome(result, TestCommand::Result.new(outcome.status == 'pass', outcome.status == 'timeout'))
|
|
367
|
+
apply_outcome(result, TestCommand::Result.new(outcome.status == 'pass', outcome.status == 'timeout', nil, tests))
|
|
384
368
|
end
|
|
385
369
|
rescue MutationTester::Error => e
|
|
386
370
|
return in_memory_worker_fallback(mutation, result, project_root, e.message) if project_root
|
|
@@ -402,6 +386,7 @@ module MutationTester
|
|
|
402
386
|
|
|
403
387
|
def subset_filters(mutation)
|
|
404
388
|
return [] unless @config.test_selection
|
|
389
|
+
return [] if @config.kill_matrix
|
|
405
390
|
return [] unless detect_test_framework(@spec_file) == :rspec
|
|
406
391
|
|
|
407
392
|
method_name = mutation[:method_name].to_s
|
|
@@ -417,6 +402,10 @@ module MutationTester
|
|
|
417
402
|
FrameworkDetector.detect(spec_path)
|
|
418
403
|
end
|
|
419
404
|
|
|
405
|
+
def recording_root
|
|
406
|
+
@recording_root ||= discoverable_project_root || File.dirname(@spec_file)
|
|
407
|
+
end
|
|
408
|
+
|
|
420
409
|
private
|
|
421
410
|
|
|
422
411
|
def with_parallel_interrupt_silenced
|
|
@@ -442,7 +431,7 @@ module MutationTester
|
|
|
442
431
|
return 'the in-memory worker failed to preload the environment'
|
|
443
432
|
end
|
|
444
433
|
|
|
445
|
-
preloaded, message = runner.preload(@spec_file, chdir: Dir.pwd, stop_on_first_failure:
|
|
434
|
+
preloaded, message = runner.preload(@spec_file, chdir: Dir.pwd, stop_on_first_failure: stop_on_first_failure?)
|
|
446
435
|
unless preloaded
|
|
447
436
|
runner.shutdown
|
|
448
437
|
return "the spec file could not be preloaded (#{message})"
|
|
@@ -479,8 +468,8 @@ module MutationTester
|
|
|
479
468
|
|
|
480
469
|
def fall_back_to_file_based(reason, mutations, completed: 0, &progress_callback)
|
|
481
470
|
announce_file_based_fallback(reason)
|
|
482
|
-
offset_callback = progress_callback && lambda do |mutation, index|
|
|
483
|
-
progress_callback.call(mutation, completed + index)
|
|
471
|
+
offset_callback = progress_callback && lambda do |mutation, index, result|
|
|
472
|
+
progress_callback.call(mutation, completed + index, result)
|
|
484
473
|
end
|
|
485
474
|
run_file_based_series(mutations, &offset_callback)
|
|
486
475
|
end
|
|
@@ -615,6 +604,16 @@ module MutationTester
|
|
|
615
604
|
@config.fail_fast && result[:status] == :survived
|
|
616
605
|
end
|
|
617
606
|
|
|
607
|
+
def parallel_progress_reporter(collected, progress_callback)
|
|
608
|
+
completed_count = 0
|
|
609
|
+
lambda do |_item, _index, result|
|
|
610
|
+
completed_count += 1
|
|
611
|
+
collected << result
|
|
612
|
+
progress_callback.call(nil, completed_count, result) if progress_callback
|
|
613
|
+
raise Parallel::Break if stop_early?(result)
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
|
|
618
617
|
def backup_path
|
|
619
618
|
self.class.backup_path_for(@source_file)
|
|
620
619
|
end
|
|
@@ -642,6 +641,17 @@ module MutationTester
|
|
|
642
641
|
result[:status] = :killed
|
|
643
642
|
end
|
|
644
643
|
result[:kill_phase] = phase if result[:killed] && phase
|
|
644
|
+
result[:killed_by] = TestRecorder.failed_ids(outcome.tests) if @config.kill_matrix && result[:status] == :killed
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def stop_on_first_failure?
|
|
648
|
+
!@config.kill_matrix
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
def recording_failed_tests(&block)
|
|
652
|
+
return [block.call(nil), nil] unless @config.kill_matrix
|
|
653
|
+
|
|
654
|
+
TestRecorder.capture(scope: :failures, root: recording_root, &block)
|
|
645
655
|
end
|
|
646
656
|
|
|
647
657
|
def mark_stillborn(result)
|
|
@@ -652,6 +662,7 @@ module MutationTester
|
|
|
652
662
|
result[:killed] = false
|
|
653
663
|
result[:timeout] = false
|
|
654
664
|
result[:status] = :error
|
|
665
|
+
result[:killed_by] = [] if result.key?(:killed_by)
|
|
655
666
|
result[:description] = "Error: #{error.message}"
|
|
656
667
|
end
|
|
657
668
|
|
|
@@ -680,7 +691,9 @@ module MutationTester
|
|
|
680
691
|
runner: @config.runner,
|
|
681
692
|
example_filters: example_filters,
|
|
682
693
|
worker_env_var: @config.worker_env_var,
|
|
683
|
-
stop_on_first_failure:
|
|
694
|
+
stop_on_first_failure: stop_on_first_failure?,
|
|
695
|
+
record: @config.kill_matrix ? :failures : nil,
|
|
696
|
+
record_root: @config.kill_matrix ? recording_root : nil
|
|
684
697
|
)
|
|
685
698
|
end
|
|
686
699
|
end
|
|
@@ -1,43 +1,84 @@
|
|
|
1
1
|
module MutationTester
|
|
2
2
|
class ProgressDisplay
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
MONOTONIC_CLOCK = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
4
|
+
ANSI_STYLE = /\e\[[\d;]*m/
|
|
5
|
+
SEPARATOR = ' | '
|
|
6
|
+
BAR_WIDTH = 20
|
|
7
|
+
LAYOUTS = [
|
|
8
|
+
{ bar_width: BAR_WIDTH, compact: false },
|
|
9
|
+
{ bar_width: BAR_WIDTH / 2, compact: false },
|
|
10
|
+
{ bar_width: BAR_WIDTH / 2, compact: true },
|
|
11
|
+
{ bar_width: 0, compact: true }
|
|
12
|
+
].freeze
|
|
13
|
+
ESTIMATE_MIN_PROCESSED = 5
|
|
14
|
+
ESTIMATE_MIN_ELAPSED = 10
|
|
15
|
+
|
|
16
|
+
attr_reader :total, :current, :survived, :timed_out, :errored
|
|
17
|
+
|
|
18
|
+
def initialize(total, config, output_stream: $stdout.clone, clock: MONOTONIC_CLOCK)
|
|
6
19
|
@output_stream = output_stream
|
|
7
20
|
@total = total
|
|
8
21
|
@current = 0
|
|
9
|
-
@
|
|
22
|
+
@survived = 0
|
|
23
|
+
@timed_out = 0
|
|
24
|
+
@errored = 0
|
|
10
25
|
@config = config
|
|
11
|
-
@
|
|
26
|
+
@clock = clock
|
|
27
|
+
@start_time = @clock.call
|
|
28
|
+
@sample_start_time = nil
|
|
29
|
+
@sample_start_count = nil
|
|
30
|
+
@rendered_length = 0
|
|
12
31
|
@spinner_frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
13
32
|
@spinner_index = 0
|
|
14
33
|
@lock = Mutex.new
|
|
15
34
|
@spinner_thread = nil
|
|
16
35
|
@stop_spinner = false
|
|
36
|
+
@halted = false
|
|
17
37
|
|
|
18
38
|
start_spinner if @config.show_progress
|
|
19
39
|
end
|
|
20
40
|
|
|
21
|
-
def update(
|
|
41
|
+
def update(index, result = nil)
|
|
22
42
|
@lock.synchronize do
|
|
43
|
+
@sample_start_time ||= @clock.call
|
|
44
|
+
@sample_start_count ||= index
|
|
23
45
|
@current = index
|
|
24
|
-
|
|
46
|
+
tally(result) if result
|
|
25
47
|
end
|
|
26
48
|
end
|
|
27
49
|
|
|
28
50
|
def finish
|
|
51
|
+
halt do
|
|
52
|
+
elapsed = @clock.call - @start_time
|
|
53
|
+
@output_stream.puts " #{Rainbow("Completed in #{format_duration(elapsed)}").green}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def stop
|
|
58
|
+
halt
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def halt
|
|
29
64
|
@lock.synchronize do
|
|
30
65
|
@stop_spinner = true
|
|
31
|
-
if @config.show_progress
|
|
66
|
+
if @config.show_progress && !@halted
|
|
32
67
|
clear_line
|
|
33
|
-
|
|
34
|
-
@output_stream.puts " #{Rainbow("Completed in #{format_duration(elapsed)}").green}"
|
|
68
|
+
yield if block_given?
|
|
35
69
|
end
|
|
70
|
+
@halted = true
|
|
36
71
|
end
|
|
37
72
|
stop_spinner
|
|
38
73
|
end
|
|
39
74
|
|
|
40
|
-
|
|
75
|
+
def tally(result)
|
|
76
|
+
case Reporters::BaseReporter.status_for(result)
|
|
77
|
+
when :survived then @survived += 1
|
|
78
|
+
when :timeout then @timed_out += 1
|
|
79
|
+
when :error then @errored += 1
|
|
80
|
+
end
|
|
81
|
+
end
|
|
41
82
|
|
|
42
83
|
def render(already_locked = false)
|
|
43
84
|
return unless @config.show_progress
|
|
@@ -51,22 +92,88 @@ module MutationTester
|
|
|
51
92
|
|
|
52
93
|
def _render_internal
|
|
53
94
|
clear_line
|
|
95
|
+
line = fitted_line
|
|
96
|
+
@rendered_length = visible_length(line)
|
|
97
|
+
@output_stream.print line
|
|
98
|
+
@output_stream.flush
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def fitted_line
|
|
102
|
+
limit = line_limit
|
|
103
|
+
now = @clock.call
|
|
104
|
+
segments = []
|
|
105
|
+
LAYOUTS.each do |layout|
|
|
106
|
+
segments = line_segments(now, **layout)
|
|
107
|
+
break if fits?(segments, limit)
|
|
108
|
+
end
|
|
109
|
+
segments.pop until fits?(segments, limit)
|
|
110
|
+
segments.join(SEPARATOR)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def fits?(segments, limit)
|
|
114
|
+
limit.nil? || visible_length(segments.join(SEPARATOR)) <= limit
|
|
115
|
+
end
|
|
54
116
|
|
|
117
|
+
def line_segments(now, bar_width:, compact:)
|
|
118
|
+
processed = Rainbow("#{@current}/#{@total}").cyan
|
|
119
|
+
|
|
120
|
+
[
|
|
121
|
+
progress_segment(bar_width),
|
|
122
|
+
compact ? processed : "#{processed} processed",
|
|
123
|
+
time_segment(now, compact),
|
|
124
|
+
tally_segment
|
|
125
|
+
]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def progress_segment(bar_width)
|
|
55
129
|
progress = (@current.to_f / @total * 100).round(1)
|
|
56
|
-
|
|
57
|
-
|
|
130
|
+
spinner = Rainbow(@spinner_frames[@spinner_index]).magenta
|
|
131
|
+
percentage = Rainbow("#{progress}%").bright
|
|
132
|
+
return "#{spinner} #{percentage}" if bar_width.zero?
|
|
58
133
|
|
|
59
|
-
|
|
134
|
+
"#{spinner} [#{create_progress_bar(progress, bar_width)}] #{percentage}"
|
|
135
|
+
end
|
|
60
136
|
|
|
61
|
-
|
|
62
|
-
|
|
137
|
+
def time_segment(now, compact)
|
|
138
|
+
elapsed = format_duration((now - @start_time).floor)
|
|
139
|
+
remaining = estimated_remaining(now)
|
|
140
|
+
return "elapsed #{elapsed}" unless remaining
|
|
63
141
|
|
|
64
|
-
|
|
65
|
-
|
|
142
|
+
left = format_duration(remaining.ceil)
|
|
143
|
+
compact ? "#{elapsed}, ~#{left} left" : "elapsed #{elapsed}, remaining ~#{left}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def tally_segment
|
|
147
|
+
survived = Rainbow("#{@survived} survived").color(@survived.zero? ? :green : :red)
|
|
148
|
+
segment = "#{survived}, #{@timed_out} timed out"
|
|
149
|
+
@errored.zero? ? segment : "#{segment}, #{Rainbow("#{@errored} errored").red}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def estimated_remaining(now)
|
|
153
|
+
return if @sample_start_time.nil? || @current >= @total
|
|
154
|
+
|
|
155
|
+
sampled = @current - @sample_start_count
|
|
156
|
+
sample_elapsed = now - @sample_start_time
|
|
157
|
+
return unless sampled.positive?
|
|
158
|
+
return if sampled < ESTIMATE_MIN_PROCESSED && sample_elapsed < ESTIMATE_MIN_ELAPSED
|
|
159
|
+
|
|
160
|
+
sample_elapsed.fdiv(sampled) * (@total - @current)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def visible_length(text)
|
|
164
|
+
text.gsub(ANSI_STYLE, '').length
|
|
66
165
|
end
|
|
67
166
|
|
|
68
|
-
def
|
|
69
|
-
|
|
167
|
+
def line_limit
|
|
168
|
+
return unless @output_stream.respond_to?(:winsize) && @output_stream.tty?
|
|
169
|
+
|
|
170
|
+
columns = @output_stream.winsize[1]
|
|
171
|
+
columns - 1 if columns.positive?
|
|
172
|
+
rescue SystemCallError
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def create_progress_bar(percentage, width = BAR_WIDTH)
|
|
70
177
|
filled = (percentage / 100.0 * width).round
|
|
71
178
|
empty = width - filled
|
|
72
179
|
|
|
@@ -75,21 +182,18 @@ module MutationTester
|
|
|
75
182
|
|
|
76
183
|
def clear_line
|
|
77
184
|
@output_stream.print "\r"
|
|
78
|
-
@output_stream.print ' ' *
|
|
185
|
+
@output_stream.print ' ' * @rendered_length
|
|
79
186
|
@output_stream.print "\r"
|
|
80
187
|
end
|
|
81
188
|
|
|
82
189
|
def format_duration(seconds)
|
|
83
|
-
if seconds < 60
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
"#{minutes}m #{secs}s"
|
|
190
|
+
return "#{seconds.round(1)}s" if seconds.round(1) < 60
|
|
191
|
+
|
|
192
|
+
whole = seconds.round
|
|
193
|
+
if whole < 3600
|
|
194
|
+
"#{whole / 60}m #{whole % 60}s"
|
|
89
195
|
else
|
|
90
|
-
|
|
91
|
-
minutes = ((seconds % 3600) / 60).floor
|
|
92
|
-
"#{hours}h #{minutes}m"
|
|
196
|
+
"#{whole / 3600}h #{whole % 3600 / 60}m"
|
|
93
197
|
end
|
|
94
198
|
end
|
|
95
199
|
|
|
@@ -17,8 +17,9 @@ module MutationTester
|
|
|
17
17
|
result[:status] || (result[:killed] ? :killed : :survived)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def initialize(results, source_file, spec_file, config, interrupted: false)
|
|
20
|
+
def initialize(results, source_file, spec_file, config, interrupted: false, tests: [])
|
|
21
21
|
@results = results
|
|
22
|
+
@tests = Array(tests)
|
|
22
23
|
@source_file = source_file
|
|
23
24
|
@spec_file = spec_file
|
|
24
25
|
@config = config
|
|
@@ -89,6 +89,7 @@ module MutationTester
|
|
|
89
89
|
|
|
90
90
|
def selection_stats_available?
|
|
91
91
|
return false unless @config.test_selection && FrameworkDetector.detect(@spec_file) == :rspec
|
|
92
|
+
return false if @config.kill_matrix
|
|
92
93
|
|
|
93
94
|
@config.runner != :in_memory || @results.any? { |result| result.key?(:kill_phase) }
|
|
94
95
|
end
|
|
@@ -21,6 +21,7 @@ module MutationTester
|
|
|
21
21
|
{
|
|
22
22
|
schema_version: SCHEMA_VERSION,
|
|
23
23
|
interrupted: interrupted?,
|
|
24
|
+
**kill_matrix_fields,
|
|
24
25
|
metadata: {
|
|
25
26
|
version: MutationTester::VERSION,
|
|
26
27
|
generated_at: Time.now.iso8601,
|
|
@@ -47,6 +48,12 @@ module MutationTester
|
|
|
47
48
|
|
|
48
49
|
private
|
|
49
50
|
|
|
51
|
+
def kill_matrix_fields
|
|
52
|
+
return {} unless @config.kill_matrix
|
|
53
|
+
|
|
54
|
+
{ kill_matrix: true, tests: @tests.map { |test| test.slice(:id, :name, :line, :status) } }
|
|
55
|
+
end
|
|
56
|
+
|
|
50
57
|
def mutation_entry(result)
|
|
51
58
|
status = status_of(result)
|
|
52
59
|
entry = result.merge(status: status)
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
require 'tempfile'
|
|
2
|
+
require_relative 'test_recorder'
|
|
2
3
|
|
|
3
4
|
module MutationTester
|
|
4
5
|
class TestCommand
|
|
5
|
-
Result = Struct.new(:passed, :timed_out, :output) do
|
|
6
|
+
Result = Struct.new(:passed, :timed_out, :output, :tests) do
|
|
6
7
|
alias_method :passed?, :passed
|
|
7
8
|
alias_method :timed_out?, :timed_out
|
|
8
9
|
end
|
|
@@ -13,7 +14,7 @@ module MutationTester
|
|
|
13
14
|
attr_reader :spec_file, :framework, :use_bundle_exec, :example_filters
|
|
14
15
|
|
|
15
16
|
def initialize(spec_file, use_bundle_exec:, framework: nil, runner: :spawn, example_filters: [], worker_env_var: nil,
|
|
16
|
-
stop_on_first_failure: false)
|
|
17
|
+
stop_on_first_failure: false, record: nil, record_root: nil)
|
|
17
18
|
@spec_file = spec_file
|
|
18
19
|
@framework = framework || self.class.detect_framework(spec_file)
|
|
19
20
|
@use_bundle_exec = use_bundle_exec
|
|
@@ -21,10 +22,12 @@ module MutationTester
|
|
|
21
22
|
@example_filters = @framework == :rspec ? Array(example_filters) : []
|
|
22
23
|
@worker_env_var = worker_env_var
|
|
23
24
|
@stop_on_first_failure = stop_on_first_failure
|
|
25
|
+
@record = record
|
|
26
|
+
@record_root = record_root
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
def argv
|
|
27
|
-
parts = [runner, *interpreter_args, spec_file, *filter_args, *fail_fast_args]
|
|
30
|
+
parts = [runner, *interpreter_args, spec_file, *filter_args, *fail_fast_args, *recorder_args]
|
|
28
31
|
@use_bundle_exec ? ['bundle', 'exec', *parts] : parts
|
|
29
32
|
end
|
|
30
33
|
|
|
@@ -41,29 +44,13 @@ module MutationTester
|
|
|
41
44
|
end
|
|
42
45
|
|
|
43
46
|
def run(timeout: nil, chdir: nil, capture: false, mirror_of: nil)
|
|
44
|
-
|
|
45
|
-
fork_runner = ForkRunner.acquire(use_bundle_exec: @use_bundle_exec, framework: @framework)
|
|
46
|
-
if fork_runner
|
|
47
|
-
return fork_runner.execute(
|
|
48
|
-
spec_file,
|
|
49
|
-
timeout: timeout,
|
|
50
|
-
chdir: chdir || Dir.pwd,
|
|
51
|
-
capture: capture,
|
|
52
|
-
args: filter_args,
|
|
53
|
-
stop_on_first_failure: @stop_on_first_failure,
|
|
54
|
-
mirror_of: mirror_of,
|
|
55
|
-
env: worker_env_overrides
|
|
56
|
-
)
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
return run_captured(timeout: timeout, chdir: chdir) if capture
|
|
61
|
-
|
|
62
|
-
spawn_options = { pgroup: true, %i[out err] => File::NULL }
|
|
63
|
-
spawn_options[:chdir] = chdir if chdir
|
|
47
|
+
return execute(timeout: timeout, chdir: chdir, capture: capture, mirror_of: mirror_of) unless @record
|
|
64
48
|
|
|
65
|
-
|
|
66
|
-
|
|
49
|
+
result, tests = TestRecorder.capture(scope: @record, root: mirror_of ? chdir : @record_root) do |recorder_env|
|
|
50
|
+
execute(timeout: timeout, chdir: chdir, capture: capture, mirror_of: mirror_of, recorder_env: recorder_env)
|
|
51
|
+
end
|
|
52
|
+
result.tests = tests
|
|
53
|
+
result
|
|
67
54
|
end
|
|
68
55
|
|
|
69
56
|
def fork_execution?
|
|
@@ -93,9 +80,42 @@ module MutationTester
|
|
|
93
80
|
|
|
94
81
|
private
|
|
95
82
|
|
|
96
|
-
def
|
|
83
|
+
def execute(timeout:, chdir:, capture:, mirror_of:, recorder_env: nil)
|
|
84
|
+
env = child_env(recorder_env)
|
|
85
|
+
if fork_execution?
|
|
86
|
+
fork_runner = ForkRunner.acquire(use_bundle_exec: @use_bundle_exec, framework: @framework)
|
|
87
|
+
if fork_runner
|
|
88
|
+
return fork_runner.execute(
|
|
89
|
+
spec_file,
|
|
90
|
+
timeout: timeout,
|
|
91
|
+
chdir: chdir || Dir.pwd,
|
|
92
|
+
capture: capture,
|
|
93
|
+
args: filter_args,
|
|
94
|
+
stop_on_first_failure: @stop_on_first_failure,
|
|
95
|
+
mirror_of: mirror_of,
|
|
96
|
+
env: env
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
return run_captured(timeout: timeout, chdir: chdir, env: env) if capture
|
|
102
|
+
|
|
103
|
+
spawn_options = { pgroup: true, %i[out err] => File::NULL }
|
|
104
|
+
spawn_options[:chdir] = chdir if chdir
|
|
105
|
+
|
|
106
|
+
pid = Process.spawn(*spawn_argv(env), spawn_options)
|
|
107
|
+
wait_with_deadline(pid, timeout)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def spawn_argv(env)
|
|
111
|
+
env ? [env, *argv] : argv
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def child_env(recorder_env)
|
|
97
115
|
overrides = worker_env_overrides
|
|
98
|
-
|
|
116
|
+
return overrides unless recorder_env
|
|
117
|
+
|
|
118
|
+
(overrides || {}).merge(recorder_env)
|
|
99
119
|
end
|
|
100
120
|
|
|
101
121
|
def worker_env_overrides
|
|
@@ -114,9 +134,18 @@ module MutationTester
|
|
|
114
134
|
end
|
|
115
135
|
|
|
116
136
|
def interpreter_args
|
|
117
|
-
return [] unless @
|
|
137
|
+
return [] unless @framework == :minitest
|
|
138
|
+
|
|
139
|
+
args = []
|
|
140
|
+
args += ['-r', MINITEST_FAIL_FAST_PATH] if @stop_on_first_failure
|
|
141
|
+
args += ['-r', TestRecorder::MINITEST_HOOK_PATH] if @record
|
|
142
|
+
args
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def recorder_args
|
|
146
|
+
return [] unless @record && @framework == :rspec
|
|
118
147
|
|
|
119
|
-
['
|
|
148
|
+
['--require', TestRecorder::RSPEC_HOOK_PATH]
|
|
120
149
|
end
|
|
121
150
|
|
|
122
151
|
def fail_fast_args
|
|
@@ -125,12 +154,12 @@ module MutationTester
|
|
|
125
154
|
['--fail-fast']
|
|
126
155
|
end
|
|
127
156
|
|
|
128
|
-
def run_captured(timeout:, chdir:)
|
|
157
|
+
def run_captured(timeout:, chdir:, env:)
|
|
129
158
|
log = Tempfile.new(['mutation_tester_baseline', '.log'])
|
|
130
159
|
spawn_options = { pgroup: true, %i[out err] => log.path }
|
|
131
160
|
spawn_options[:chdir] = chdir if chdir
|
|
132
161
|
|
|
133
|
-
pid = Process.spawn(*spawn_argv, spawn_options)
|
|
162
|
+
pid = Process.spawn(*spawn_argv(env), spawn_options)
|
|
134
163
|
result = wait_with_deadline(pid, timeout)
|
|
135
164
|
result.output = File.read(log.path)
|
|
136
165
|
result
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../test_recorder'
|
|
4
|
+
require_relative '../minitest_load_hook'
|
|
5
|
+
|
|
6
|
+
MutationTester::MinitestLoadHook.on_load do
|
|
7
|
+
module MutationTester
|
|
8
|
+
module TestRecorder
|
|
9
|
+
module MinitestHook
|
|
10
|
+
PLUGIN_NAME = :mutation_tester_test_recorder
|
|
11
|
+
|
|
12
|
+
class Reporter < Minitest::AbstractReporter
|
|
13
|
+
def record(result)
|
|
14
|
+
return unless TestRecorder.active?
|
|
15
|
+
|
|
16
|
+
TestRecorder.write([entry_for(result)])
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def entry_for(result)
|
|
22
|
+
owner = result.respond_to?(:klass) && result.klass ? result.klass : result.class.name
|
|
23
|
+
{
|
|
24
|
+
id: "#{owner}##{result.name}",
|
|
25
|
+
name: result.name,
|
|
26
|
+
line: line_of(result),
|
|
27
|
+
status: status_of(result)
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def line_of(result)
|
|
32
|
+
location = result.respond_to?(:source_location) ? result.source_location : nil
|
|
33
|
+
location.is_a?(Array) ? location[1] : nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def status_of(result)
|
|
37
|
+
return 'skipped' if result.skipped?
|
|
38
|
+
|
|
39
|
+
result.passed? ? 'passed' : 'failed'
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
module Minitest
|
|
47
|
+
def self.plugin_mutation_tester_test_recorder_init(_options)
|
|
48
|
+
reporter << MutationTester::TestRecorder::MinitestHook::Reporter.new
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
unless Minitest.extensions.include?(MutationTester::TestRecorder::MinitestHook::PLUGIN_NAME)
|
|
53
|
+
Minitest.register_plugin(MutationTester::TestRecorder::MinitestHook::PLUGIN_NAME)
|
|
54
|
+
end
|
|
55
|
+
end
|