mutaterb 0.1.0 → 0.3.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/README.md +14 -5
- data/lib/mutaterb/cli.rb +32 -8
- data/lib/mutaterb/config.rb +27 -7
- data/lib/mutaterb/flag_parser.rb +2 -0
- data/lib/mutaterb/mutator.rb +26 -11
- data/lib/mutaterb/progress_reporter.rb +124 -0
- data/lib/mutaterb/project_detector.rb +33 -7
- data/lib/mutaterb/reporter.rb +3 -1
- data/lib/mutaterb/test_adapters/minitest_adapter.rb +63 -0
- data/lib/mutaterb/test_adapters/rspec_adapter.rb +34 -0
- data/lib/mutaterb/test_runner.rb +39 -49
- data/lib/mutaterb/test_suite.rb +6 -4
- data/lib/mutaterb/version.rb +1 -1
- data/lib/mutaterb.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0fcfd443fe916c062548f30e91d88d26a3315995867ef5171ae65a59075a8f30
|
|
4
|
+
data.tar.gz: 349a834f109c1ab01cbf632ca042fa2850691c88ef77c61250c2d37d8e7def6b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c46d5fcaf78d48abde487f77f96803ec0748e011b20d6d00cfb9d00766ae69e94c3403b3dd9be35867f2ff854608cfb13e218cd82c5a94d7635b5077861b6df2
|
|
7
|
+
data.tar.gz: a7327b9f520ebae9231729c43b8f29cbb171e35f6781eef35889c63ff5502dc9cd3ce431833e7b77ae9b759065648cc805af39b7b2fb8f4b3c14c440e2be145e
|
data/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# MutateRB
|
|
2
2
|
|
|
3
3
|
A mutation testing tool for Ruby and Ruby on Rails projects: it modifies code covered
|
|
4
|
-
by your
|
|
5
|
-
|
|
4
|
+
by your test suite and checks whether it catches the change. If a mutated test does not
|
|
5
|
+
fail, that test is identified as weak. Supports both RSpec and Minitest.
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
@@ -25,9 +25,16 @@ mutaterb
|
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
Without flags, it automatically detects whether the project is pure Ruby or Rails,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
which test framework it uses (RSpec if there's a `spec/` folder, Minitest if there's a
|
|
29
|
+
`test/` folder — RSpec wins if both are present), applies mutations to covered code,
|
|
30
|
+
and displays a summary: how many mutations were "killed" (caught by a test) and how
|
|
31
|
+
many "survived" (no test caught them — weak tests), with file, line, and related
|
|
32
|
+
test(s) for each survivor.
|
|
33
|
+
|
|
34
|
+
While it runs, it prints the baseline status and a live counter of mutants processed
|
|
35
|
+
so far out of the total, so a long run never looks stuck. Pass `--verbose` to replace
|
|
36
|
+
that counter with one line per mutant (file, line, mutation type, and result) as it is
|
|
37
|
+
evaluated.
|
|
31
38
|
|
|
32
39
|
### Main flags
|
|
33
40
|
|
|
@@ -37,8 +44,10 @@ caught them — weak tests), with file, line, and related test(s) for each survi
|
|
|
37
44
|
| `--include PATHS` | Restrict analysis to these paths (comma-separated) |
|
|
38
45
|
| `--exclude PATHS` | Exclude these paths (comma-separated) |
|
|
39
46
|
| `--strictness LEVEL` | `low`, `default`, or `high` |
|
|
47
|
+
| `--framework FRAMEWORK` | `auto`, `rspec`, or `minitest` — forces the test framework instead of auto-detecting it |
|
|
40
48
|
| `--mutation-types TYPES` | Mutation types to apply, comma-separated |
|
|
41
49
|
| `--exit-zero` | Do not fail (exit 0) even if "survived" mutations exist |
|
|
50
|
+
| `--verbose` | Print one line per mutant as it runs, instead of the default counter |
|
|
42
51
|
| `--json-output PATH` | Export the results summary to a JSON file |
|
|
43
52
|
| `--config PATH` | Use a config file other than `.mutaterb.yml` |
|
|
44
53
|
|
data/lib/mutaterb/cli.rb
CHANGED
|
@@ -39,14 +39,16 @@ module MutateRB
|
|
|
39
39
|
Signal.trap("INT") { interrupted = true }
|
|
40
40
|
|
|
41
41
|
detection = ProjectDetector.new(config).detect
|
|
42
|
-
if detection.
|
|
42
|
+
if detection.test_files.empty?
|
|
43
43
|
warn "mutaterb: no tests found in #{config.target_dir}"
|
|
44
44
|
return EXIT_OPERATIONAL_ERROR
|
|
45
45
|
end
|
|
46
|
+
warn_ambiguous_frameworks(detection)
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
progress = ProgressReporter.new(verbose: config.verbose)
|
|
49
|
+
test_suite = build_test_suite(config, detection, progress)
|
|
48
50
|
run = MutationRun.new(config: config, test_suite: test_suite)
|
|
49
|
-
run_mutations(run, test_suite, config) { interrupted }
|
|
51
|
+
run_mutations(run, test_suite, config, progress) { interrupted }
|
|
50
52
|
|
|
51
53
|
run.finished_at = Time.now
|
|
52
54
|
run.interrupted = interrupted
|
|
@@ -57,18 +59,40 @@ module MutateRB
|
|
|
57
59
|
run.exit_code
|
|
58
60
|
end
|
|
59
61
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
# FR-002/SC-003: when both frameworks are present and none was forced
|
|
63
|
+
# explicitly, the choice must be visible, never silent.
|
|
64
|
+
def warn_ambiguous_frameworks(detection)
|
|
65
|
+
return unless detection.ambiguous_frameworks
|
|
66
|
+
|
|
67
|
+
warn "mutaterb: detected both RSpec and Minitest — using #{detection.test_framework} " \
|
|
68
|
+
"(force one explicitly with --framework)"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def build_test_suite(config, detection, progress)
|
|
72
|
+
test_runner = TestRunner.new(config: config, project_type: detection.project_type,
|
|
73
|
+
framework: detection.test_framework)
|
|
74
|
+
progress.start_baseline
|
|
75
|
+
baseline_examples = test_runner.run_baseline(detection.test_files)
|
|
76
|
+
progress.finish_baseline
|
|
77
|
+
TestSuite.from_baseline(project_type: detection.project_type, framework: detection.test_framework,
|
|
78
|
+
baseline_examples: baseline_examples)
|
|
64
79
|
end
|
|
65
80
|
|
|
66
|
-
def run_mutations(run, test_suite, config)
|
|
81
|
+
def run_mutations(run, test_suite, config, progress)
|
|
67
82
|
mutator = Mutator.new(config: config, test_suite: test_suite)
|
|
83
|
+
total = mutator.total_mutants
|
|
84
|
+
if total.zero?
|
|
85
|
+
warn "mutaterb: no mutants to run for the given scope"
|
|
86
|
+
return
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
progress.start_mutants(total)
|
|
68
90
|
mutator.each_mutant do |mutant|
|
|
69
91
|
run.add_mutant(mutant)
|
|
92
|
+
progress.mutant_finished(mutant)
|
|
70
93
|
break if yield
|
|
71
94
|
end
|
|
95
|
+
progress.finish_mutants
|
|
72
96
|
end
|
|
73
97
|
end
|
|
74
98
|
end
|
data/lib/mutaterb/config.rb
CHANGED
|
@@ -8,14 +8,17 @@ module MutateRB
|
|
|
8
8
|
class Config
|
|
9
9
|
ALL_MUTATION_TYPES = %i[conditional_boundary boolean_literal nil_literal arithmetic_comparison].freeze
|
|
10
10
|
VALID_STRICTNESS = %i[low default high].freeze
|
|
11
|
+
VALID_TEST_FRAMEWORKS = %i[auto rspec minitest].freeze
|
|
11
12
|
DEFAULT_FILE_NAME = ".mutaterb.yml"
|
|
12
13
|
|
|
13
14
|
attr_accessor :target_dir, :include_paths, :exclude_paths, :strictness,
|
|
14
|
-
:mutation_types, :exit_on_survivors, :json_output_path
|
|
15
|
+
:mutation_types, :exit_on_survivors, :json_output_path, :test_framework,
|
|
16
|
+
:verbose
|
|
15
17
|
|
|
16
18
|
def initialize(target_dir: ".", include_paths: [], exclude_paths: [],
|
|
17
19
|
strictness: :default, mutation_types: ALL_MUTATION_TYPES.dup,
|
|
18
|
-
exit_on_survivors: true, json_output_path: nil
|
|
20
|
+
exit_on_survivors: true, json_output_path: nil, test_framework: :auto,
|
|
21
|
+
verbose: false)
|
|
19
22
|
@target_dir = target_dir
|
|
20
23
|
@include_paths = include_paths
|
|
21
24
|
@exclude_paths = exclude_paths
|
|
@@ -23,6 +26,8 @@ module MutateRB
|
|
|
23
26
|
@mutation_types = mutation_types
|
|
24
27
|
@exit_on_survivors = exit_on_survivors
|
|
25
28
|
@json_output_path = json_output_path
|
|
29
|
+
@test_framework = test_framework
|
|
30
|
+
@verbose = verbose
|
|
26
31
|
validate!
|
|
27
32
|
end
|
|
28
33
|
|
|
@@ -46,7 +51,7 @@ module MutateRB
|
|
|
46
51
|
|
|
47
52
|
def self.attributes_from_yaml(raw)
|
|
48
53
|
known_keys = %w[target_dir include_paths exclude_paths strictness mutation_types
|
|
49
|
-
exit_on_survivors json_output_path]
|
|
54
|
+
exit_on_survivors json_output_path test_framework verbose]
|
|
50
55
|
raw.each_key do |key|
|
|
51
56
|
warn "mutaterb: ignoring unknown config key #{key.inspect}" unless known_keys.include?(key)
|
|
52
57
|
end
|
|
@@ -64,7 +69,9 @@ module MutateRB
|
|
|
64
69
|
ALL_MUTATION_TYPES.dup
|
|
65
70
|
end,
|
|
66
71
|
exit_on_survivors: raw.fetch("exit_on_survivors", true),
|
|
67
|
-
json_output_path: raw["json_output_path"]
|
|
72
|
+
json_output_path: raw["json_output_path"],
|
|
73
|
+
test_framework: raw.key?("test_framework") ? symbolize(raw["test_framework"], "test_framework") : :auto,
|
|
74
|
+
verbose: raw.fetch("verbose", false)
|
|
68
75
|
}
|
|
69
76
|
end
|
|
70
77
|
private_class_method :attributes_from_yaml
|
|
@@ -94,15 +101,28 @@ module MutateRB
|
|
|
94
101
|
raise ConfigError, "target_dir #{target_dir.inspect} does not exist" unless Dir.exist?(target_dir)
|
|
95
102
|
raise ConfigError, "include_paths must be an Array" unless include_paths.is_a?(Array)
|
|
96
103
|
raise ConfigError, "exclude_paths must be an Array" unless exclude_paths.is_a?(Array)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
104
|
+
|
|
105
|
+
validate_enum!(:strictness, strictness, VALID_STRICTNESS)
|
|
106
|
+
validate_enum!(:test_framework, test_framework, VALID_TEST_FRAMEWORKS)
|
|
100
107
|
raise ConfigError, "mutation_types must be an Array" unless mutation_types.is_a?(Array)
|
|
101
108
|
|
|
102
109
|
unknown = mutation_types - ALL_MUTATION_TYPES
|
|
103
110
|
raise ConfigError, "unknown mutation_types: #{unknown.join(', ')}" unless unknown.empty?
|
|
104
111
|
raise ConfigError, "exit_on_survivors must be true or false" unless [true, false].include?(exit_on_survivors)
|
|
112
|
+
raise ConfigError, "verbose must be true or false" unless [true, false].include?(verbose)
|
|
113
|
+
|
|
114
|
+
validate_json_output_path!
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def validate_enum!(field, value, allowed)
|
|
120
|
+
return if allowed.include?(value)
|
|
121
|
+
|
|
122
|
+
raise ConfigError, "#{field} must be one of #{allowed.join(', ')}, got #{value.inspect}"
|
|
123
|
+
end
|
|
105
124
|
|
|
125
|
+
def validate_json_output_path!
|
|
106
126
|
return unless json_output_path
|
|
107
127
|
|
|
108
128
|
parent = File.dirname(File.expand_path(json_output_path))
|
data/lib/mutaterb/flag_parser.rb
CHANGED
|
@@ -35,6 +35,7 @@ module MutateRB
|
|
|
35
35
|
flags[:exclude_paths] = v.split(",")
|
|
36
36
|
end
|
|
37
37
|
opts.on("--strictness LEVEL", "low|default|high") { |v| flags[:strictness] = v.to_sym }
|
|
38
|
+
opts.on("--framework FRAMEWORK", "auto|rspec|minitest") { |v| flags[:test_framework] = v.to_sym }
|
|
38
39
|
opts.on("--mutation-types TYPES", "Mutation types to apply, comma-separated") do |v|
|
|
39
40
|
flags[:mutation_types] = v.split(",").map(&:to_sym)
|
|
40
41
|
end
|
|
@@ -47,6 +48,7 @@ module MutateRB
|
|
|
47
48
|
opts.on("--config PATH", "Use a config file other than .mutaterb.yml") do |v|
|
|
48
49
|
flags[:config_path] = v
|
|
49
50
|
end
|
|
51
|
+
opts.on("--verbose", "Print one line per mutant as it runs") { flags[:verbose] = true }
|
|
50
52
|
opts.on("-h", "--help", "Show this help") do
|
|
51
53
|
puts opts
|
|
52
54
|
exit 0
|
data/lib/mutaterb/mutator.rb
CHANGED
|
@@ -15,23 +15,34 @@ module MutateRB
|
|
|
15
15
|
def initialize(config:, test_suite:, test_runner: nil)
|
|
16
16
|
@config = config
|
|
17
17
|
@test_suite = test_suite
|
|
18
|
-
@test_runner = test_runner || TestRunner.new(config: config, project_type: test_suite.project_type
|
|
18
|
+
@test_runner = test_runner || TestRunner.new(config: config, project_type: test_suite.project_type,
|
|
19
|
+
framework: test_suite.framework)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Total number of mutants this run will process, known upfront without running any
|
|
23
|
+
# test (FR-002, FR-008) — memoized alongside `candidates` (research.md #3).
|
|
24
|
+
def total_mutants
|
|
25
|
+
candidates.size
|
|
19
26
|
end
|
|
20
27
|
|
|
21
28
|
# Yields each finished Mutant, one at a time.
|
|
22
29
|
def each_mutant
|
|
23
|
-
|
|
24
|
-
candidates_for(file).each { |mutant| yield run_one(mutant) }
|
|
25
|
-
end
|
|
30
|
+
candidates.each { |mutant| yield run_one(mutant) }
|
|
26
31
|
end
|
|
27
32
|
|
|
28
33
|
private
|
|
29
34
|
|
|
30
35
|
attr_reader :config, :test_suite, :test_runner
|
|
31
36
|
|
|
37
|
+
# Memoized: discovering candidates is pure parsing (no test execution), so computing
|
|
38
|
+
# it once upfront lets `total_mutants` be known before `each_mutant` starts (FR-002).
|
|
39
|
+
def candidates
|
|
40
|
+
@candidates ||= source_files.flat_map { |file| candidates_for(file) }
|
|
41
|
+
end
|
|
42
|
+
|
|
32
43
|
def source_files
|
|
33
44
|
Dir.glob(File.join(config.target_dir, "**", "*.rb"))
|
|
34
|
-
.reject { |f| f.include?("/spec/") || f.
|
|
45
|
+
.reject { |f| f.include?("/spec/") || f.include?("/test/") }
|
|
35
46
|
.select { |f| in_scope?(f) }
|
|
36
47
|
end
|
|
37
48
|
|
|
@@ -127,22 +138,26 @@ module MutateRB
|
|
|
127
138
|
end
|
|
128
139
|
end
|
|
129
140
|
|
|
130
|
-
# Convention-based coverage mapping: lib/foo/bar.rb -> spec/foo/bar_spec.rb
|
|
141
|
+
# Convention-based coverage mapping: lib/foo/bar.rb -> spec/foo/bar_spec.rb
|
|
142
|
+
# (or test/foo/bar_test.rb for Minitest, feature 004 research.md #5).
|
|
131
143
|
# ponytail: no real coverage tracking yet; falls back to the whole suite
|
|
132
|
-
# when no matching
|
|
144
|
+
# when no matching test file exists. Upgrade path: integrate SimpleCov
|
|
133
145
|
# coverage data if this heuristic proves too coarse for real projects.
|
|
134
146
|
def related_tests_for(source_file)
|
|
135
|
-
mapped =
|
|
147
|
+
mapped = mapped_test_file(source_file)
|
|
136
148
|
matches = test_suite.test_cases.select { |t| t.file_path == mapped }
|
|
137
149
|
matches = test_suite.test_cases.dup if matches.empty?
|
|
138
150
|
matches.reject(&:baseline_broken?)
|
|
139
151
|
end
|
|
140
152
|
|
|
141
|
-
def
|
|
153
|
+
def mapped_test_file(source_file)
|
|
142
154
|
relative = source_file.sub(%r{\A#{Regexp.escape(config.target_dir)}/?}, "")
|
|
143
155
|
relative = relative.sub(%r{\A(lib|app)/}, "")
|
|
144
|
-
|
|
145
|
-
|
|
156
|
+
if test_suite.framework == :minitest
|
|
157
|
+
File.join(config.target_dir, "test", relative.sub(/\.rb\z/, "_test.rb"))
|
|
158
|
+
else
|
|
159
|
+
File.join(config.target_dir, "spec", relative.sub(/\.rb\z/, "_spec.rb"))
|
|
160
|
+
end
|
|
146
161
|
end
|
|
147
162
|
end
|
|
148
163
|
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MutateRB
|
|
4
|
+
# Live feedback for a run in progress (FR-001 to FR-005, FR-007): mutaterb used to print
|
|
5
|
+
# nothing between start and the final summary, so a long run was indistinguishable from a
|
|
6
|
+
# hang. A background thread ticks a status line on a timer, independent of whether the
|
|
7
|
+
# baseline suite or a single mutant's tests have actually finished (FR-003a) — the real work
|
|
8
|
+
# happens inside an opaque, blocking subprocess call, so this is the only way to keep the
|
|
9
|
+
# terminal changing during a slow phase.
|
|
10
|
+
class ProgressReporter
|
|
11
|
+
SPINNER_FRAMES = %w[| / - \\].freeze
|
|
12
|
+
TTY_TICK_INTERVAL = 0.3
|
|
13
|
+
NON_TTY_TICK_INTERVAL = 5
|
|
14
|
+
|
|
15
|
+
def initialize(io: $stdout, verbose: false, tick_interval: nil)
|
|
16
|
+
@io = io
|
|
17
|
+
@verbose = verbose
|
|
18
|
+
@tick_interval = tick_interval || (io.tty? ? TTY_TICK_INTERVAL : NON_TTY_TICK_INTERVAL)
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
@phase = :idle
|
|
21
|
+
@processed = 0
|
|
22
|
+
@total = 0
|
|
23
|
+
@phase_started_at = nil
|
|
24
|
+
@ticker_thread = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def start_baseline
|
|
28
|
+
@mutex.synchronize do
|
|
29
|
+
@phase = :baseline
|
|
30
|
+
@phase_started_at = Time.now
|
|
31
|
+
end
|
|
32
|
+
@io.puts "Running baseline tests..."
|
|
33
|
+
start_ticker
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def finish_baseline
|
|
37
|
+
stop_ticker
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def start_mutants(total)
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
@phase = :mutating
|
|
43
|
+
@processed = 0
|
|
44
|
+
@total = total
|
|
45
|
+
@phase_started_at = Time.now
|
|
46
|
+
end
|
|
47
|
+
start_ticker unless @verbose
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# In verbose mode, this line IS the progress indicator for the mutant loop (FR-005):
|
|
51
|
+
# the bare counter/ticker never runs, so there is nothing to interleave with it.
|
|
52
|
+
def mutant_finished(mutant)
|
|
53
|
+
if @verbose
|
|
54
|
+
print_mutant_line(mutant)
|
|
55
|
+
else
|
|
56
|
+
@mutex.synchronize { @processed += 1 }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def finish_mutants
|
|
61
|
+
stop_ticker
|
|
62
|
+
@mutex.synchronize { @phase = :done }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def print_mutant_line(mutant)
|
|
68
|
+
@io.puts "#{mutant.file_path}:#{mutant.line} [#{mutant.operator_type}] " \
|
|
69
|
+
"'#{mutant.original_fragment}' -> '#{mutant.mutated_fragment}' => #{mutant.status}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def start_ticker
|
|
73
|
+
@ticker_thread = Thread.new { tick_loop }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def stop_ticker
|
|
77
|
+
return unless @ticker_thread
|
|
78
|
+
|
|
79
|
+
@ticker_thread.kill
|
|
80
|
+
@ticker_thread.join
|
|
81
|
+
@ticker_thread = nil
|
|
82
|
+
@io.puts if @io.tty?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Never lets a rendering bug abort the real mutation run (Principio V): worst case, the
|
|
86
|
+
# ticker just stops drawing and the run continues silently, exactly like before this
|
|
87
|
+
# feature existed.
|
|
88
|
+
def tick_loop
|
|
89
|
+
loop do
|
|
90
|
+
sleep @tick_interval
|
|
91
|
+
draw
|
|
92
|
+
end
|
|
93
|
+
rescue StandardError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def draw
|
|
98
|
+
line = current_line
|
|
99
|
+
return unless line
|
|
100
|
+
|
|
101
|
+
if @io.tty?
|
|
102
|
+
@io.print "\r#{line}"
|
|
103
|
+
@io.flush
|
|
104
|
+
else
|
|
105
|
+
@io.puts line
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def current_line
|
|
110
|
+
@mutex.synchronize do
|
|
111
|
+
next nil unless @phase_started_at
|
|
112
|
+
|
|
113
|
+
elapsed = Time.now - @phase_started_at
|
|
114
|
+
spinner = SPINNER_FRAMES[(elapsed / @tick_interval).to_i % SPINNER_FRAMES.size]
|
|
115
|
+
case @phase
|
|
116
|
+
when :baseline
|
|
117
|
+
"Running baseline tests... (#{spinner} #{elapsed.round}s)"
|
|
118
|
+
when :mutating
|
|
119
|
+
"Mutating... #{@processed}/#{@total} (#{spinner} #{elapsed.round}s)"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module MutateRB
|
|
4
|
-
# Detects whether the target project is Ruby or Rails
|
|
5
|
-
# files (FR-001,
|
|
4
|
+
# Detects whether the target project is Ruby or Rails, which test framework
|
|
5
|
+
# it uses (RSpec and/or Minitest), and locates its test files (FR-001,
|
|
6
|
+
# FR-002, FR-003).
|
|
6
7
|
class ProjectDetector
|
|
7
|
-
Detection = Struct.new(:project_type, :
|
|
8
|
+
Detection = Struct.new(:project_type, :test_framework, :test_files, :ambiguous_frameworks)
|
|
8
9
|
|
|
9
10
|
def initialize(config)
|
|
10
11
|
@config = config
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def detect
|
|
14
|
-
|
|
15
|
+
framework, ambiguous = resolve_test_framework
|
|
16
|
+
Detection.new(project_type, framework, discover_test_files(framework), ambiguous)
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
private
|
|
@@ -24,9 +26,33 @@ module MutateRB
|
|
|
24
26
|
File.exist?(rails_marker) || File.exist?(rails_bin) ? :rails : :ruby
|
|
25
27
|
end
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
# research.md #1: an explicit config.test_framework always wins; otherwise
|
|
30
|
+
# detect by which test files actually exist, RSpec winning when both do.
|
|
31
|
+
def resolve_test_framework
|
|
32
|
+
return [config.test_framework, false] unless config.test_framework == :auto
|
|
33
|
+
|
|
34
|
+
has_rspec = test_files?(:rspec)
|
|
35
|
+
has_minitest = test_files?(:minitest)
|
|
36
|
+
return [:rspec, true] if has_rspec && has_minitest
|
|
37
|
+
return [:minitest, false] if has_minitest && !has_rspec
|
|
38
|
+
|
|
39
|
+
[:rspec, false]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def test_files?(framework)
|
|
43
|
+
!Dir.glob(test_file_pattern(framework)).empty?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def discover_test_files(framework)
|
|
47
|
+
apply_scope(Dir.glob(test_file_pattern(framework)))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def test_file_pattern(framework)
|
|
51
|
+
if framework == :minitest
|
|
52
|
+
File.join(config.target_dir, "test", "**", "*_test.rb")
|
|
53
|
+
else
|
|
54
|
+
File.join(config.target_dir, "spec", "**", "*_spec.rb")
|
|
55
|
+
end
|
|
30
56
|
end
|
|
31
57
|
|
|
32
58
|
def apply_scope(files)
|
data/lib/mutaterb/reporter.rb
CHANGED
|
@@ -70,11 +70,13 @@ module MutateRB
|
|
|
70
70
|
c = run.config
|
|
71
71
|
{
|
|
72
72
|
target_dir: c.target_dir,
|
|
73
|
+
framework: run.test_suite.framework.to_s,
|
|
73
74
|
include_paths: c.include_paths,
|
|
74
75
|
exclude_paths: c.exclude_paths,
|
|
75
76
|
strictness: c.strictness.to_s,
|
|
76
77
|
mutation_types: c.mutation_types.map(&:to_s),
|
|
77
|
-
exit_on_survivors: c.exit_on_survivors
|
|
78
|
+
exit_on_survivors: c.exit_on_survivors,
|
|
79
|
+
verbose: c.verbose
|
|
78
80
|
}
|
|
79
81
|
end
|
|
80
82
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module MutateRB
|
|
6
|
+
module TestAdapters
|
|
7
|
+
# Builds a Minitest invocation and parses its native `--verbose` output —
|
|
8
|
+
# no added dependency in the target project (FR-009, research.md #2/#4).
|
|
9
|
+
#
|
|
10
|
+
# Minitest's own runner only knows how to run one file's worth of tests
|
|
11
|
+
# per process, and its verbose line format doesn't name the source file,
|
|
12
|
+
# so this adapter spawns one process per file (joined with `&&` into a
|
|
13
|
+
# single shell command, so TestRunner still only tracks one pid) and
|
|
14
|
+
# emits a marker line between them to attribute each parsed example back
|
|
15
|
+
# to the file it came from.
|
|
16
|
+
class MinitestAdapter
|
|
17
|
+
LINE_PATTERN = /^(\S+)\s*=\s*([\d.]+)\s*s\s*=\s*([.FES])/
|
|
18
|
+
FILE_MARKER = "@@MUTATERB_FILE@@"
|
|
19
|
+
|
|
20
|
+
def self.command_for(files, project_type:)
|
|
21
|
+
files.map { |file| "echo #{FILE_MARKER}#{Shellwords.escape(file)} && #{run_one(file, project_type)}" }
|
|
22
|
+
.join(" && ")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.run_one(file, project_type)
|
|
26
|
+
runner = project_type == :rails ? "bin/rails test" : "bundle exec ruby -Itest -Ilib"
|
|
27
|
+
"#{runner} #{Shellwords.escape(file)} -v"
|
|
28
|
+
end
|
|
29
|
+
private_class_method :run_one
|
|
30
|
+
|
|
31
|
+
def self.parse(raw, files: [])
|
|
32
|
+
current_file = files.first
|
|
33
|
+
examples = []
|
|
34
|
+
raw.each_line do |line|
|
|
35
|
+
if (marker = line[/\A#{Regexp.escape(FILE_MARKER)}(.+)/, 1])
|
|
36
|
+
current_file = marker.strip
|
|
37
|
+
next
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
append_example(examples, line, current_file)
|
|
41
|
+
end
|
|
42
|
+
{ status: :completed, examples: examples }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.append_example(examples, line, file)
|
|
46
|
+
match = line.match(LINE_PATTERN)
|
|
47
|
+
return unless match
|
|
48
|
+
|
|
49
|
+
result_char = match[3]
|
|
50
|
+
return if result_char == "S" # skipped: excluded, not passed/failed (research.md #2)
|
|
51
|
+
|
|
52
|
+
examples << {
|
|
53
|
+
id: match[1],
|
|
54
|
+
description: match[1],
|
|
55
|
+
file_path: file,
|
|
56
|
+
status: result_char == "." ? :passed : :failed,
|
|
57
|
+
duration: match[2].to_f
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
private_class_method :append_example
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
|
|
6
|
+
module MutateRB
|
|
7
|
+
module TestAdapters
|
|
8
|
+
# Builds the `bundle exec rspec` command and parses its `--format json`
|
|
9
|
+
# output (extracted from TestRunner, research.md #3 of feature 004 —
|
|
10
|
+
# MutateRB never bundles its own RSpec, it shells out to the target
|
|
11
|
+
# project's own).
|
|
12
|
+
class RspecAdapter
|
|
13
|
+
def self.command_for(files, project_type:) # rubocop:disable Lint/UnusedMethodArgument
|
|
14
|
+
"bundle exec rspec --format json #{Shellwords.join(files)}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.parse(raw, files: []) # rubocop:disable Lint/UnusedMethodArgument
|
|
18
|
+
data = JSON.parse(raw)
|
|
19
|
+
examples = data.fetch("examples", []).map do |example|
|
|
20
|
+
{
|
|
21
|
+
id: example["id"],
|
|
22
|
+
description: example["full_description"],
|
|
23
|
+
file_path: example["file_path"],
|
|
24
|
+
status: example["status"] == "passed" ? :passed : :failed,
|
|
25
|
+
duration: example["run_time"].to_f
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
{ status: :completed, examples: examples }
|
|
29
|
+
rescue JSON::ParserError
|
|
30
|
+
{ status: :error, examples: [] }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/mutaterb/test_runner.rb
CHANGED
|
@@ -1,28 +1,34 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
3
|
require "timeout"
|
|
5
4
|
require "bundler"
|
|
6
5
|
|
|
7
6
|
module MutateRB
|
|
8
|
-
# Runs the target project's own
|
|
9
|
-
# MutateRB never bundles its own RSpec, it shells
|
|
10
|
-
#
|
|
11
|
-
#
|
|
7
|
+
# Runs the target project's own test suite as a subprocess (research.md #2
|
|
8
|
+
# of feature 001): MutateRB never bundles its own RSpec/Minitest, it shells
|
|
9
|
+
# out to the target project's own. The actual command and output parsing
|
|
10
|
+
# are delegated to a framework-specific adapter (research.md #3 of feature
|
|
11
|
+
# 004); this class owns spawning, timeouts, and killing hung processes.
|
|
12
12
|
class TestRunner
|
|
13
13
|
MIN_TIMEOUT_SECONDS = 5
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
ADAPTERS = {
|
|
16
|
+
rspec: TestAdapters::RspecAdapter,
|
|
17
|
+
minitest: TestAdapters::MinitestAdapter
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def initialize(config:, project_type:, framework: :rspec)
|
|
16
21
|
@config = config
|
|
17
22
|
@project_type = project_type
|
|
23
|
+
@adapter = ADAPTERS.fetch(framework)
|
|
18
24
|
end
|
|
19
25
|
|
|
20
|
-
# Runs the given
|
|
21
|
-
#
|
|
26
|
+
# Runs the given test files once, unmutated, and returns one Hash per
|
|
27
|
+
# test example: { id:, description:, file_path:, status:, duration: }.
|
|
22
28
|
# Used to build TestCase instances (FR-012's baseline_status) and as the
|
|
23
29
|
# basis for the per-mutant timeout (FR-014).
|
|
24
|
-
def run_baseline(
|
|
25
|
-
|
|
30
|
+
def run_baseline(files)
|
|
31
|
+
spawn_test_command(files, timeout_seconds: nil).fetch(:examples, [])
|
|
26
32
|
end
|
|
27
33
|
|
|
28
34
|
# Runs the given test cases against a mutation. Timeout = 2x the slowest
|
|
@@ -35,82 +41,66 @@ module MutateRB
|
|
|
35
41
|
slowest_baseline = test_cases.filter_map(&:baseline_duration_seconds).max || 0
|
|
36
42
|
timeout_seconds = [slowest_baseline * 2, MIN_TIMEOUT_SECONDS].max
|
|
37
43
|
files = test_cases.map(&:file_path).uniq
|
|
38
|
-
|
|
44
|
+
spawn_test_command(files, timeout_seconds: timeout_seconds)
|
|
39
45
|
end
|
|
40
46
|
|
|
41
47
|
private
|
|
42
48
|
|
|
43
|
-
attr_reader :config, :project_type
|
|
49
|
+
attr_reader :config, :project_type, :adapter
|
|
44
50
|
|
|
45
51
|
# Runs inside Bundler.with_unbundled_env (research.md #2): when `mutaterb`
|
|
46
52
|
# itself is invoked via `bundle exec`, BUNDLE_GEMFILE/RUBYOPT point at
|
|
47
|
-
# MutateRB's own Gemfile and would otherwise leak into this child process
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
|
|
53
|
+
# MutateRB's own Gemfile and would otherwise leak into this child process.
|
|
54
|
+
# Spawned in its own process group (pgroup: true) so that when the
|
|
55
|
+
# adapter's command is a shell pipeline (Minitest's multi-file case joins
|
|
56
|
+
# several invocations with `&&`), killing the group also reaches the
|
|
57
|
+
# actual test process, not just the intermediate shell.
|
|
58
|
+
def spawn_test_command(files, timeout_seconds:)
|
|
52
59
|
env = project_type == :rails ? { "RAILS_ENV" => "test" } : {}
|
|
60
|
+
command = adapter.command_for(files, project_type: project_type)
|
|
53
61
|
stdout_read, stdout_write = IO.pipe
|
|
54
62
|
pid = Bundler.with_unbundled_env do
|
|
55
|
-
Process.spawn(env,
|
|
56
|
-
|
|
63
|
+
Process.spawn(env, command, out: stdout_write, err: File::NULL, pgroup: true,
|
|
64
|
+
chdir: File.expand_path(config.target_dir))
|
|
57
65
|
end
|
|
58
66
|
stdout_write.close
|
|
59
67
|
|
|
60
|
-
output = wait_with_timeout(pid, stdout_read, timeout_seconds)
|
|
68
|
+
output = wait_with_timeout(pid, stdout_read, timeout_seconds, files)
|
|
61
69
|
stdout_read.close unless stdout_read.closed?
|
|
62
70
|
output
|
|
63
71
|
end
|
|
64
72
|
|
|
65
|
-
def wait_with_timeout(pid, stdout_read, timeout_seconds)
|
|
73
|
+
def wait_with_timeout(pid, stdout_read, timeout_seconds, files)
|
|
66
74
|
if timeout_seconds
|
|
67
|
-
Timeout.timeout(timeout_seconds) { read_and_wait(pid, stdout_read) }
|
|
75
|
+
Timeout.timeout(timeout_seconds) { read_and_wait(pid, stdout_read, files) }
|
|
68
76
|
else
|
|
69
|
-
read_and_wait(pid, stdout_read)
|
|
77
|
+
read_and_wait(pid, stdout_read, files)
|
|
70
78
|
end
|
|
71
79
|
rescue Timeout::Error
|
|
72
80
|
kill(pid)
|
|
73
81
|
{ status: :timeout, examples: [] }
|
|
74
82
|
end
|
|
75
83
|
|
|
76
|
-
def read_and_wait(pid, stdout_read)
|
|
84
|
+
def read_and_wait(pid, stdout_read, files)
|
|
77
85
|
raw = stdout_read.read
|
|
78
86
|
Process.wait(pid)
|
|
79
|
-
|
|
87
|
+
adapter.parse(raw, files: files)
|
|
80
88
|
end
|
|
81
89
|
|
|
82
|
-
# Kills a hung child process: TERM first, KILL if it ignores TERM
|
|
83
|
-
# (research.md #3 — Timeout alone never touches the
|
|
90
|
+
# Kills a hung child process group: TERM first, KILL if it ignores TERM
|
|
91
|
+
# for 1s (research.md #3 of feature 001 — Timeout alone never touches the
|
|
92
|
+
# child process). Signals the whole process group (negative pid) so a
|
|
93
|
+
# shell-wrapped command's real child is reached too.
|
|
84
94
|
def kill(pid)
|
|
85
|
-
Process.kill("TERM", pid)
|
|
95
|
+
Process.kill("TERM", -pid)
|
|
86
96
|
begin
|
|
87
97
|
Timeout.timeout(1) { Process.wait(pid) }
|
|
88
98
|
rescue Timeout::Error
|
|
89
|
-
Process.kill("KILL", pid)
|
|
99
|
+
Process.kill("KILL", -pid)
|
|
90
100
|
Process.wait(pid)
|
|
91
101
|
end
|
|
92
102
|
rescue Errno::ESRCH, Errno::ECHILD
|
|
93
103
|
nil
|
|
94
104
|
end
|
|
95
|
-
|
|
96
|
-
def rspec_command(files)
|
|
97
|
-
["bundle", "exec", "rspec", "--format", "json", *files]
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def parse_output(raw)
|
|
101
|
-
data = JSON.parse(raw)
|
|
102
|
-
examples = data.fetch("examples", []).map do |example|
|
|
103
|
-
{
|
|
104
|
-
id: example["id"],
|
|
105
|
-
description: example["full_description"],
|
|
106
|
-
file_path: example["file_path"],
|
|
107
|
-
status: example["status"] == "passed" ? :passed : :failed,
|
|
108
|
-
duration: example["run_time"].to_f
|
|
109
|
-
}
|
|
110
|
-
end
|
|
111
|
-
{ status: :completed, examples: examples }
|
|
112
|
-
rescue JSON::ParserError
|
|
113
|
-
{ status: :error, examples: [] }
|
|
114
|
-
end
|
|
115
105
|
end
|
|
116
106
|
end
|
data/lib/mutaterb/test_suite.rb
CHANGED
|
@@ -4,23 +4,25 @@ module MutateRB
|
|
|
4
4
|
# The collection of tests detected for the target project (data-model.md).
|
|
5
5
|
class TestSuite
|
|
6
6
|
VALID_PROJECT_TYPES = %i[ruby rails].freeze
|
|
7
|
+
VALID_FRAMEWORKS = %i[rspec minitest].freeze
|
|
7
8
|
|
|
8
9
|
attr_reader :framework, :project_type, :test_cases
|
|
9
10
|
|
|
10
|
-
def initialize(project_type:, test_cases: [])
|
|
11
|
+
def initialize(project_type:, framework: :rspec, test_cases: [])
|
|
11
12
|
unless VALID_PROJECT_TYPES.include?(project_type)
|
|
12
13
|
raise ArgumentError,
|
|
13
14
|
"invalid project_type #{project_type.inspect}"
|
|
14
15
|
end
|
|
16
|
+
raise ArgumentError, "invalid framework #{framework.inspect}" unless VALID_FRAMEWORKS.include?(framework)
|
|
15
17
|
|
|
16
|
-
@framework =
|
|
18
|
+
@framework = framework
|
|
17
19
|
@project_type = project_type
|
|
18
20
|
@test_cases = Array(test_cases)
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
# Builds a TestSuite from the raw example hashes returned by
|
|
22
24
|
# TestRunner#run_baseline (FR-012's baseline_status comes from here).
|
|
23
|
-
def self.from_baseline(project_type:, baseline_examples:)
|
|
25
|
+
def self.from_baseline(project_type:, framework:, baseline_examples:)
|
|
24
26
|
test_cases = baseline_examples.map do |example|
|
|
25
27
|
TestCase.new(
|
|
26
28
|
id: example.fetch(:id),
|
|
@@ -30,7 +32,7 @@ module MutateRB
|
|
|
30
32
|
baseline_duration_seconds: example.fetch(:duration)
|
|
31
33
|
)
|
|
32
34
|
end
|
|
33
|
-
new(project_type: project_type, test_cases: test_cases)
|
|
35
|
+
new(project_type: project_type, framework: framework, test_cases: test_cases)
|
|
34
36
|
end
|
|
35
37
|
|
|
36
38
|
def find(id)
|
data/lib/mutaterb/version.rb
CHANGED
data/lib/mutaterb.rb
CHANGED
|
@@ -9,6 +9,8 @@ require_relative "mutaterb/config"
|
|
|
9
9
|
require_relative "mutaterb/mutation_run"
|
|
10
10
|
require_relative "mutaterb/flag_parser"
|
|
11
11
|
require_relative "mutaterb/project_detector"
|
|
12
|
+
require_relative "mutaterb/test_adapters/rspec_adapter"
|
|
13
|
+
require_relative "mutaterb/test_adapters/minitest_adapter"
|
|
12
14
|
require_relative "mutaterb/test_runner"
|
|
13
15
|
require_relative "mutaterb/mutation_operators/base_operator"
|
|
14
16
|
require_relative "mutaterb/mutation_operators/conditional_boundary_operator"
|
|
@@ -16,6 +18,7 @@ require_relative "mutaterb/mutation_operators/boolean_literal_operator"
|
|
|
16
18
|
require_relative "mutaterb/mutation_operators/nil_literal_operator"
|
|
17
19
|
require_relative "mutaterb/mutation_operators/arithmetic_comparison_operator"
|
|
18
20
|
require_relative "mutaterb/mutator"
|
|
21
|
+
require_relative "mutaterb/progress_reporter"
|
|
19
22
|
require_relative "mutaterb/reporter"
|
|
20
23
|
require_relative "mutaterb/cli"
|
|
21
24
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mutaterb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- MarceloM47
|
|
@@ -76,8 +76,11 @@ files:
|
|
|
76
76
|
- lib/mutaterb/mutation_operators/nil_literal_operator.rb
|
|
77
77
|
- lib/mutaterb/mutation_run.rb
|
|
78
78
|
- lib/mutaterb/mutator.rb
|
|
79
|
+
- lib/mutaterb/progress_reporter.rb
|
|
79
80
|
- lib/mutaterb/project_detector.rb
|
|
80
81
|
- lib/mutaterb/reporter.rb
|
|
82
|
+
- lib/mutaterb/test_adapters/minitest_adapter.rb
|
|
83
|
+
- lib/mutaterb/test_adapters/rspec_adapter.rb
|
|
81
84
|
- lib/mutaterb/test_case.rb
|
|
82
85
|
- lib/mutaterb/test_runner.rb
|
|
83
86
|
- lib/mutaterb/test_suite.rb
|