evilution 0.21.0 → 0.22.1
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/.beads/.gitignore +4 -0
- data/.beads/interactions.jsonl +16 -0
- data/.beads/issues.jsonl +9 -6
- data/.claude/settings.json +5 -0
- data/CHANGELOG.md +35 -0
- data/README.md +28 -13
- data/comparison_results/baseline_2026-04-09.md +35 -0
- data/comparison_results/operator_classification.md +79 -0
- data/comparison_results/operator_prioritization.md +68 -0
- data/docs/mutation_density_benchmark.md +91 -0
- data/lib/evilution/ast/parser.rb +2 -1
- data/lib/evilution/baseline.rb +14 -11
- data/lib/evilution/cli.rb +2 -1
- data/lib/evilution/config.rb +15 -3
- data/lib/evilution/disable_comment.rb +2 -1
- data/lib/evilution/integration/base.rb +124 -1
- data/lib/evilution/integration/minitest.rb +145 -0
- data/lib/evilution/integration/minitest_crash_detector.rb +55 -0
- data/lib/evilution/integration/rspec.rb +33 -100
- data/lib/evilution/isolation/fork.rb +11 -3
- data/lib/evilution/isolation/in_process.rb +12 -3
- data/lib/evilution/mcp/mutate_tool.rb +6 -6
- data/lib/evilution/mutator/base.rb +4 -0
- data/lib/evilution/mutator/operator/bitwise_complement.rb +1 -1
- data/lib/evilution/mutator/operator/block_pass_removal.rb +30 -0
- data/lib/evilution/mutator/operator/ensure_removal.rb +1 -1
- data/lib/evilution/mutator/operator/index_to_at.rb +30 -0
- data/lib/evilution/mutator/operator/index_to_dig.rb +2 -2
- data/lib/evilution/mutator/operator/index_to_fetch.rb +2 -2
- data/lib/evilution/mutator/operator/keyword_argument.rb +1 -1
- data/lib/evilution/mutator/operator/regex_simplification.rb +169 -0
- data/lib/evilution/mutator/operator/rescue_body_replacement.rb +1 -1
- data/lib/evilution/mutator/operator/rescue_removal.rb +1 -1
- data/lib/evilution/mutator/operator/symbol_literal.rb +9 -0
- data/lib/evilution/mutator/registry.rb +3 -0
- data/lib/evilution/reporter/cli.rb +19 -0
- data/lib/evilution/reporter/html.rb +12 -3
- data/lib/evilution/reporter/json.rb +14 -3
- data/lib/evilution/reporter/suggestion.rb +659 -2
- data/lib/evilution/result/mutation_result.rb +9 -2
- data/lib/evilution/runner.rb +56 -17
- data/lib/evilution/spec_resolver.rb +24 -16
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +4 -0
- data/script/memory_check +5 -5
- data/scripts/benchmark_density +261 -0
- data/scripts/benchmark_density.yml +19 -0
- data/scripts/compare_mutations +404 -0
- data/scripts/compare_mutations.yml +24 -0
- data/scripts/mutant_json_adapter +224 -0
- metadata +17 -2
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Mutation Density Benchmark Methodology
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Track and close the mutation density gap between evilution and a reference
|
|
6
|
+
mutation testing tool.
|
|
7
|
+
Current gap: **1.8-2.6x** (evilution generates fewer mutations).
|
|
8
|
+
Target: **< 1.5x** across the benchmark corpus.
|
|
9
|
+
|
|
10
|
+
## Metric
|
|
11
|
+
|
|
12
|
+
**Density ratio** = `reference_mutations / evilution_mutations` per file.
|
|
13
|
+
|
|
14
|
+
A ratio of 1.0 means parity. Values above 1.0 mean the reference tool generates
|
|
15
|
+
more. The aggregate ratio is computed from total mutations across all benchmark
|
|
16
|
+
files (not an average of per-file ratios, which would over-weight small files).
|
|
17
|
+
|
|
18
|
+
## Measurement Protocol
|
|
19
|
+
|
|
20
|
+
### Benchmark corpus
|
|
21
|
+
|
|
22
|
+
Select **10 files** from a real-world Rails project covering diverse patterns:
|
|
23
|
+
|
|
24
|
+
| Slot | Category | Example |
|
|
25
|
+
|------|----------------------|----------------------------------|
|
|
26
|
+
| 1 | Controller | `app/controllers/*_controller.rb`|
|
|
27
|
+
| 2 | Model (ActiveRecord) | `app/models/*.rb` |
|
|
28
|
+
| 3 | Service object | `app/services/*.rb` |
|
|
29
|
+
| 4 | Validator | `app/validators/*.rb` |
|
|
30
|
+
| 5 | Concern / mixin | `app/models/concerns/*.rb` |
|
|
31
|
+
| 6 | Helper | `app/helpers/*.rb` |
|
|
32
|
+
| 7 | Formatter / presenter| `app/presenters/*.rb` |
|
|
33
|
+
| 8 | Lib utility | `lib/*.rb` |
|
|
34
|
+
| 9 | Job / worker | `app/jobs/*.rb` |
|
|
35
|
+
| 10 | Configuration / DSL | `config/initializers/*.rb` |
|
|
36
|
+
|
|
37
|
+
Files should be **50-300 LOC** (enough mutations to be meaningful, small enough
|
|
38
|
+
to run quickly). The exact file list is stored in the benchmark config file
|
|
39
|
+
(`scripts/benchmark_density.yml`).
|
|
40
|
+
|
|
41
|
+
### Tool configuration
|
|
42
|
+
|
|
43
|
+
Both tools must run with equivalent settings:
|
|
44
|
+
|
|
45
|
+
- **evilution**: default operators, no `--skip-heredoc-literals`, no ignore patterns
|
|
46
|
+
- **reference tool**: default operator set, no timeout (we only count, not run)
|
|
47
|
+
|
|
48
|
+
The benchmark counts **generated mutations**, not killed/survived. This isolates
|
|
49
|
+
operator coverage from test quality.
|
|
50
|
+
|
|
51
|
+
### Running the benchmark
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Count-only mode (fast, no test execution):
|
|
55
|
+
scripts/benchmark_density scripts/benchmark_density.yml
|
|
56
|
+
|
|
57
|
+
# Full output with per-file breakdown:
|
|
58
|
+
scripts/benchmark_density scripts/benchmark_density.yml --verbose
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Output
|
|
62
|
+
|
|
63
|
+
The script produces a table:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
File Evilution Reference Ratio
|
|
67
|
+
app/models/user.rb 42 78 1.86x
|
|
68
|
+
app/services/payment.rb 31 52 1.68x
|
|
69
|
+
...
|
|
70
|
+
TOTAL 312 534 1.71x
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
And a summary: `Density ratio: 1.71x (target: < 1.50x)`.
|
|
74
|
+
|
|
75
|
+
## When to Run
|
|
76
|
+
|
|
77
|
+
- **Before each release** that adds new operators
|
|
78
|
+
- **After closing operator issues** from the gap analysis epic (GH #515)
|
|
79
|
+
- **On demand** when evaluating whether a proposed operator is worth adding
|
|
80
|
+
|
|
81
|
+
## Interpreting Results
|
|
82
|
+
|
|
83
|
+
- **Ratio < 1.5x**: target met
|
|
84
|
+
- **Ratio 1.5-2.0x**: progress, but more operators needed
|
|
85
|
+
- **Ratio > 2.0x**: significant gap remains
|
|
86
|
+
- **Per-file outliers**: files with ratio > 3.0x likely expose a missing operator category
|
|
87
|
+
|
|
88
|
+
Not all extra mutations from the reference tool are valuable. Some produce
|
|
89
|
+
equivalent mutants (semantically identical code). The head-to-head comparison
|
|
90
|
+
(GH #523) classifies each extra mutation as signal vs noise. The density ratio
|
|
91
|
+
is a **coarse progress metric**, not a quality score.
|
data/lib/evilution/ast/parser.rb
CHANGED
|
@@ -70,7 +70,8 @@ module Evilution::AST
|
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
loc = node.location
|
|
73
|
-
method_source = @source
|
|
73
|
+
method_source = @source.byteslice(loc.start_offset, loc.end_offset - loc.start_offset)
|
|
74
|
+
.force_encoding(@source.encoding)
|
|
74
75
|
|
|
75
76
|
@subjects << Evilution::Subject.new(
|
|
76
77
|
name: name,
|
data/lib/evilution/baseline.rb
CHANGED
|
@@ -14,9 +14,11 @@ class Evilution::Baseline
|
|
|
14
14
|
end
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def initialize(spec_resolver: Evilution::SpecResolver.new, timeout: 30)
|
|
17
|
+
def initialize(spec_resolver: Evilution::SpecResolver.new, timeout: 30, runner: nil, fallback_dir: "spec")
|
|
18
18
|
@spec_resolver = spec_resolver
|
|
19
19
|
@timeout = timeout
|
|
20
|
+
@runner = runner
|
|
21
|
+
@fallback_dir = fallback_dir
|
|
20
22
|
end
|
|
21
23
|
|
|
22
24
|
def call(subjects)
|
|
@@ -33,10 +35,14 @@ class Evilution::Baseline
|
|
|
33
35
|
end
|
|
34
36
|
|
|
35
37
|
def run_spec_file(spec_file)
|
|
38
|
+
raise Evilution::Error, "no baseline runner configured" unless @runner
|
|
39
|
+
|
|
36
40
|
read_io, write_io = IO.pipe
|
|
37
41
|
pid = fork_spec_runner(spec_file, read_io, write_io)
|
|
38
42
|
write_io.close
|
|
39
43
|
read_result(read_io, pid)
|
|
44
|
+
rescue Evilution::Error
|
|
45
|
+
raise
|
|
40
46
|
rescue StandardError
|
|
41
47
|
false
|
|
42
48
|
ensure
|
|
@@ -45,19 +51,16 @@ class Evilution::Baseline
|
|
|
45
51
|
end
|
|
46
52
|
|
|
47
53
|
def fork_spec_runner(spec_file, read_io, write_io)
|
|
54
|
+
runner = @runner
|
|
48
55
|
Process.fork do
|
|
49
56
|
read_io.close
|
|
50
57
|
$stdout.reopen(File::NULL, "w")
|
|
51
58
|
$stderr.reopen(File::NULL, "w")
|
|
52
59
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
status = RSpec::Core::Runner.run(
|
|
56
|
-
["--format", "progress", "--no-color", "--order", "defined", spec_file]
|
|
57
|
-
)
|
|
58
|
-
Marshal.dump({ passed: status.zero? }, write_io)
|
|
60
|
+
passed = runner.call(spec_file)
|
|
61
|
+
Marshal.dump({ passed: passed }, write_io)
|
|
59
62
|
write_io.close
|
|
60
|
-
exit!(
|
|
63
|
+
exit!(passed ? 0 : 1)
|
|
61
64
|
end
|
|
62
65
|
end
|
|
63
66
|
|
|
@@ -97,10 +100,10 @@ class Evilution::Baseline
|
|
|
97
100
|
subjects.map do |s|
|
|
98
101
|
resolved = @spec_resolver.call(s.file_path)
|
|
99
102
|
if resolved.nil? && warned.add?(s.file_path)
|
|
100
|
-
warn "[evilution] No matching
|
|
101
|
-
"Use --spec to specify the
|
|
103
|
+
warn "[evilution] No matching test found for #{s.file_path}, running full suite. " \
|
|
104
|
+
"Use --spec to specify the test file."
|
|
102
105
|
end
|
|
103
|
-
resolved ||
|
|
106
|
+
resolved || @fallback_dir
|
|
104
107
|
end.uniq
|
|
105
108
|
end
|
|
106
109
|
end
|
data/lib/evilution/cli.rb
CHANGED
|
@@ -247,9 +247,10 @@ class Evilution::CLI
|
|
|
247
247
|
"(default: disabled; if provided without N, uses 1; use --fail-fast=N)") { @options[:fail_fast] ||= 1 }
|
|
248
248
|
opts.on("--no-baseline", "Skip baseline test suite check") { @options[:baseline] = false }
|
|
249
249
|
opts.on("--incremental", "Cache killed/timeout results; skip re-running them on unchanged files") { @options[:incremental] = true }
|
|
250
|
+
opts.on("--integration NAME", "Test integration: rspec, minitest (default: rspec)") { |i| @options[:integration] = i }
|
|
250
251
|
opts.on("--isolation STRATEGY", "Isolation: auto, fork, in_process (default: auto)") { |s| @options[:isolation] = s }
|
|
251
252
|
opts.on("--stdin", "Read target file paths from stdin (one per line)") { @options[:stdin] = true }
|
|
252
|
-
opts.on("--suggest-tests", "Generate concrete
|
|
253
|
+
opts.on("--suggest-tests", "Generate concrete test code in suggestions (RSpec or Minitest)") { @options[:suggest_tests] = true }
|
|
253
254
|
opts.on("--no-progress", "Disable progress bar") { @options[:progress] = false }
|
|
254
255
|
add_extra_flag_options(opts)
|
|
255
256
|
end
|
data/lib/evilution/config.rb
CHANGED
|
@@ -125,7 +125,7 @@ class Evilution::Config
|
|
|
125
125
|
# Minimum mutation score to pass (0.0 to 1.0, default: 0.0)
|
|
126
126
|
# min_score: 0.0
|
|
127
127
|
|
|
128
|
-
# Test integration: rspec (default: rspec)
|
|
128
|
+
# Test integration: rspec, minitest (default: rspec)
|
|
129
129
|
# integration: rspec
|
|
130
130
|
|
|
131
131
|
# Number of parallel workers (default: 1)
|
|
@@ -134,7 +134,7 @@ class Evilution::Config
|
|
|
134
134
|
# Stop after N surviving mutants (default: disabled)
|
|
135
135
|
# fail_fast: 1
|
|
136
136
|
|
|
137
|
-
# Generate concrete
|
|
137
|
+
# Generate concrete test code in suggestions, matching integration (default: false)
|
|
138
138
|
# suggest_tests: false
|
|
139
139
|
|
|
140
140
|
# Skip all string literal mutations inside heredocs (default: false)
|
|
@@ -172,7 +172,7 @@ class Evilution::Config
|
|
|
172
172
|
@format = merged[:format].to_sym
|
|
173
173
|
@target = merged[:target]
|
|
174
174
|
@min_score = merged[:min_score].to_f
|
|
175
|
-
@integration = merged[:integration]
|
|
175
|
+
@integration = validate_integration(merged[:integration])
|
|
176
176
|
@verbose = merged[:verbose]
|
|
177
177
|
@quiet = merged[:quiet]
|
|
178
178
|
@jobs = validate_jobs(merged[:jobs])
|
|
@@ -192,6 +192,18 @@ class Evilution::Config
|
|
|
192
192
|
@hooks = validate_hooks(merged[:hooks])
|
|
193
193
|
end
|
|
194
194
|
|
|
195
|
+
def validate_integration(value)
|
|
196
|
+
raise Evilution::ConfigError, "integration must be rspec or minitest, got nil" if value.nil?
|
|
197
|
+
|
|
198
|
+
value = value.to_sym
|
|
199
|
+
unless %i[rspec minitest].include?(value)
|
|
200
|
+
raise Evilution::ConfigError,
|
|
201
|
+
"integration must be rspec or minitest, got #{value.inspect}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
value
|
|
205
|
+
end
|
|
206
|
+
|
|
195
207
|
def validate_isolation(value)
|
|
196
208
|
raise Evilution::ConfigError, "isolation must be auto, fork, or in_process, got nil" if value.nil?
|
|
197
209
|
|
|
@@ -22,7 +22,8 @@ class Evilution::DisableComment
|
|
|
22
22
|
def classify_comments(parse_result, source)
|
|
23
23
|
parse_result.comments.filter_map do |comment|
|
|
24
24
|
loc = comment.location
|
|
25
|
-
text = source
|
|
25
|
+
text = source.byteslice(loc.start_offset, loc.end_offset - loc.start_offset)
|
|
26
|
+
.force_encoding(source.encoding)
|
|
26
27
|
|
|
27
28
|
if text.match?(DISABLE_MARKER)
|
|
28
29
|
line = source.lines[loc.start_line - 1]
|
|
@@ -1,13 +1,136 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
3
5
|
require_relative "../integration"
|
|
6
|
+
require_relative "../temp_dir_tracker"
|
|
4
7
|
|
|
5
8
|
class Evilution::Integration::Base
|
|
9
|
+
def self.baseline_runner
|
|
10
|
+
raise NotImplementedError, "#{name}.baseline_runner must be implemented"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.baseline_options
|
|
14
|
+
raise NotImplementedError, "#{name}.baseline_options must be implemented"
|
|
15
|
+
end
|
|
16
|
+
|
|
6
17
|
def initialize(hooks: nil)
|
|
7
18
|
@hooks = hooks
|
|
8
19
|
end
|
|
9
20
|
|
|
10
21
|
def call(mutation)
|
|
11
|
-
|
|
22
|
+
@temp_dir = nil
|
|
23
|
+
ensure_framework_loaded
|
|
24
|
+
fire_hook(:mutation_insert_pre, mutation: mutation, file_path: mutation.file_path)
|
|
25
|
+
load_error = apply_mutation(mutation)
|
|
26
|
+
return load_error if load_error
|
|
27
|
+
|
|
28
|
+
fire_hook(:mutation_insert_post, mutation: mutation, file_path: mutation.file_path)
|
|
29
|
+
run_tests(mutation)
|
|
30
|
+
ensure
|
|
31
|
+
restore_original(mutation)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def ensure_framework_loaded
|
|
37
|
+
raise NotImplementedError, "#{self.class}#ensure_framework_loaded must be implemented"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def run_tests(_mutation)
|
|
41
|
+
raise NotImplementedError, "#{self.class}#run_tests must be implemented"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def build_args(_mutation)
|
|
45
|
+
raise NotImplementedError, "#{self.class}#build_args must be implemented"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def reset_state
|
|
49
|
+
raise NotImplementedError, "#{self.class}#reset_state must be implemented"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def fire_hook(event, **payload)
|
|
53
|
+
@hooks.fire(event, **payload) if @hooks
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def apply_mutation(mutation)
|
|
57
|
+
@temp_dir = Dir.mktmpdir("evilution")
|
|
58
|
+
Evilution::TempDirTracker.register(@temp_dir)
|
|
59
|
+
@displaced_feature = nil
|
|
60
|
+
subpath = resolve_require_subpath(mutation.file_path)
|
|
61
|
+
|
|
62
|
+
if subpath
|
|
63
|
+
apply_via_require(mutation, subpath)
|
|
64
|
+
else
|
|
65
|
+
apply_via_load(mutation)
|
|
66
|
+
end
|
|
67
|
+
nil
|
|
68
|
+
rescue SyntaxError => e
|
|
69
|
+
{
|
|
70
|
+
passed: false,
|
|
71
|
+
error: "syntax error in mutated source: #{e.message}",
|
|
72
|
+
error_class: e.class.name,
|
|
73
|
+
error_backtrace: Array(e.backtrace).first(5)
|
|
74
|
+
}
|
|
75
|
+
rescue ScriptError, StandardError => e
|
|
76
|
+
{
|
|
77
|
+
passed: false,
|
|
78
|
+
error: "#{e.class}: #{e.message}",
|
|
79
|
+
error_class: e.class.name,
|
|
80
|
+
error_backtrace: Array(e.backtrace).first(5)
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def apply_via_require(mutation, subpath)
|
|
85
|
+
dest = File.join(@temp_dir, subpath)
|
|
86
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
87
|
+
File.write(dest, mutation.mutated_source)
|
|
88
|
+
$LOAD_PATH.unshift(@temp_dir)
|
|
89
|
+
displace_loaded_feature(mutation.file_path)
|
|
90
|
+
require(subpath.delete_suffix(".rb"))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def apply_via_load(mutation)
|
|
94
|
+
absolute = File.expand_path(mutation.file_path)
|
|
95
|
+
dest = File.join(@temp_dir, absolute)
|
|
96
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
97
|
+
File.write(dest, mutation.mutated_source)
|
|
98
|
+
load(dest)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def restore_original(_mutation)
|
|
102
|
+
return unless @temp_dir
|
|
103
|
+
|
|
104
|
+
$LOAD_PATH.delete(@temp_dir)
|
|
105
|
+
$LOADED_FEATURES.reject! { |f| f.start_with?(@temp_dir) }
|
|
106
|
+
$LOADED_FEATURES << @displaced_feature if @displaced_feature && !$LOADED_FEATURES.include?(@displaced_feature)
|
|
107
|
+
@displaced_feature = nil
|
|
108
|
+
FileUtils.rm_rf(@temp_dir)
|
|
109
|
+
Evilution::TempDirTracker.unregister(@temp_dir)
|
|
110
|
+
@temp_dir = nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def resolve_require_subpath(file_path)
|
|
114
|
+
absolute = File.expand_path(file_path)
|
|
115
|
+
best_subpath = nil
|
|
116
|
+
|
|
117
|
+
$LOAD_PATH.each do |entry|
|
|
118
|
+
dir = File.expand_path(entry)
|
|
119
|
+
prefix = dir.end_with?("/") ? dir : "#{dir}/"
|
|
120
|
+
next unless absolute.start_with?(prefix)
|
|
121
|
+
|
|
122
|
+
candidate = absolute.delete_prefix(prefix)
|
|
123
|
+
best_subpath = candidate if best_subpath.nil? || candidate.length < best_subpath.length
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
best_subpath
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def displace_loaded_feature(file_path)
|
|
130
|
+
absolute = File.expand_path(file_path)
|
|
131
|
+
return unless $LOADED_FEATURES.include?(absolute)
|
|
132
|
+
|
|
133
|
+
@displaced_feature = absolute
|
|
134
|
+
$LOADED_FEATURES.delete(absolute)
|
|
12
135
|
end
|
|
13
136
|
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
require_relative "minitest_crash_detector"
|
|
6
|
+
require_relative "../spec_resolver"
|
|
7
|
+
|
|
8
|
+
require_relative "../integration"
|
|
9
|
+
|
|
10
|
+
class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
11
|
+
def self.baseline_runner
|
|
12
|
+
lambda { |test_file|
|
|
13
|
+
require "minitest"
|
|
14
|
+
require "stringio"
|
|
15
|
+
::Minitest::Runnable.runnables.clear
|
|
16
|
+
files = File.directory?(test_file) ? Dir.glob(File.join(test_file, "**/*_test.rb")) : [test_file]
|
|
17
|
+
files.each { |f| load(File.expand_path(f)) }
|
|
18
|
+
out = StringIO.new
|
|
19
|
+
options = ::Minitest.process_args(["--seed", "0"])
|
|
20
|
+
options[:io] = out
|
|
21
|
+
reporter = ::Minitest::CompositeReporter.new
|
|
22
|
+
reporter << ::Minitest::SummaryReporter.new(out, options)
|
|
23
|
+
reporter.start
|
|
24
|
+
::Minitest.__run(reporter, options)
|
|
25
|
+
reporter.report
|
|
26
|
+
reporter.passed?
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.baseline_options
|
|
31
|
+
{
|
|
32
|
+
runner: baseline_runner,
|
|
33
|
+
spec_resolver: Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration"),
|
|
34
|
+
fallback_dir: "test"
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(test_files: nil, hooks: nil)
|
|
39
|
+
@test_files = test_files
|
|
40
|
+
@minitest_loaded = false
|
|
41
|
+
@spec_resolver = Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
|
|
42
|
+
@crash_detector = nil
|
|
43
|
+
@warned_files = Set.new
|
|
44
|
+
super(hooks: hooks)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
attr_reader :test_files
|
|
50
|
+
|
|
51
|
+
def ensure_framework_loaded
|
|
52
|
+
return if @minitest_loaded
|
|
53
|
+
|
|
54
|
+
fire_hook(:setup_integration_pre, integration: :minitest)
|
|
55
|
+
require "minitest"
|
|
56
|
+
@minitest_loaded = true
|
|
57
|
+
fire_hook(:setup_integration_post, integration: :minitest)
|
|
58
|
+
rescue LoadError => e
|
|
59
|
+
raise Evilution::Error, "minitest is required but not available: #{e.message}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def run_tests(mutation)
|
|
63
|
+
reset_state
|
|
64
|
+
files = resolve_test_files(mutation)
|
|
65
|
+
command = "ruby -Itest #{files.join(" ")}"
|
|
66
|
+
|
|
67
|
+
files.each { |f| load(File.expand_path(f)) }
|
|
68
|
+
|
|
69
|
+
args = build_args(mutation)
|
|
70
|
+
detector = reset_crash_detector
|
|
71
|
+
passed = run_minitest(args, detector)
|
|
72
|
+
|
|
73
|
+
build_minitest_result(passed, command, detector)
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
{ passed: false, error: e.message, test_command: command }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def build_args(_mutation)
|
|
79
|
+
["--seed", "0"]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def reset_state
|
|
83
|
+
::Minitest::Runnable.runnables.clear
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def run_minitest(args, detector)
|
|
87
|
+
out = StringIO.new
|
|
88
|
+
options = ::Minitest.process_args(args)
|
|
89
|
+
options[:io] = out
|
|
90
|
+
|
|
91
|
+
reporter = ::Minitest::CompositeReporter.new
|
|
92
|
+
reporter << ::Minitest::SummaryReporter.new(out, options)
|
|
93
|
+
reporter << detector
|
|
94
|
+
|
|
95
|
+
reporter.start
|
|
96
|
+
::Minitest.__run(reporter, options)
|
|
97
|
+
reporter.report
|
|
98
|
+
|
|
99
|
+
reporter.passed?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def reset_crash_detector
|
|
103
|
+
if @crash_detector
|
|
104
|
+
@crash_detector.reset
|
|
105
|
+
else
|
|
106
|
+
@crash_detector = Evilution::Integration::MinitestCrashDetector.new
|
|
107
|
+
end
|
|
108
|
+
@crash_detector
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def build_minitest_result(passed, command, detector)
|
|
112
|
+
if passed
|
|
113
|
+
{ passed: true, test_command: command }
|
|
114
|
+
elsif detector.only_crashes?
|
|
115
|
+
{ passed: false, error: "test crashes: #{detector.crash_summary}", test_command: command }
|
|
116
|
+
else
|
|
117
|
+
{ passed: false, test_command: command }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def resolve_test_files(mutation)
|
|
122
|
+
return test_files if test_files
|
|
123
|
+
|
|
124
|
+
resolved = @spec_resolver.call(mutation.file_path)
|
|
125
|
+
unless resolved
|
|
126
|
+
warn_unresolved_test(mutation.file_path)
|
|
127
|
+
return glob_test_files
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
[resolved]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def glob_test_files
|
|
134
|
+
files = Dir.glob("test/**/*_test.rb")
|
|
135
|
+
files.empty? ? ["test"] : files
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def warn_unresolved_test(file_path)
|
|
139
|
+
return if @warned_files.include?(file_path)
|
|
140
|
+
|
|
141
|
+
@warned_files << file_path
|
|
142
|
+
warn "[evilution] No matching test found for #{file_path}, running full suite. " \
|
|
143
|
+
"Use --spec to specify the test file."
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../integration"
|
|
4
|
+
|
|
5
|
+
class Evilution::Integration::MinitestCrashDetector
|
|
6
|
+
def initialize
|
|
7
|
+
reset
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def start
|
|
11
|
+
# Required by Minitest reporter interface
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def report
|
|
15
|
+
# Required by Minitest reporter interface
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def passed?
|
|
19
|
+
@crashes.empty?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def reset
|
|
23
|
+
@assertion_failures = 0
|
|
24
|
+
@crashes = []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def record(result)
|
|
28
|
+
result.failures.each do |failure|
|
|
29
|
+
if failure.is_a?(::Minitest::UnexpectedError)
|
|
30
|
+
@crashes << failure.error
|
|
31
|
+
elsif failure.is_a?(::Minitest::Assertion)
|
|
32
|
+
@assertion_failures += 1
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def has_assertion_failure? # rubocop:disable Naming/PredicatePrefix
|
|
38
|
+
@assertion_failures.positive?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def has_crash? # rubocop:disable Naming/PredicatePrefix
|
|
42
|
+
@crashes.any?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def only_crashes?
|
|
46
|
+
@crashes.any? && @assertion_failures.zero?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def crash_summary
|
|
50
|
+
return nil if @crashes.empty?
|
|
51
|
+
|
|
52
|
+
types = @crashes.map { |e| e.class.name }.uniq
|
|
53
|
+
"#{types.join(", ")} (#{@crashes.length} crash#{"es" unless @crashes.length == 1})"
|
|
54
|
+
end
|
|
55
|
+
end
|