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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e60dd9f48be985c4852b318ee38410f6ce76cb924604c9d37f1f64004e2c27f6
4
- data.tar.gz: 6ef1b8387ed290681db0ab7480021fe2536782cf57478834d56f1331b3676ecc
3
+ metadata.gz: 0fcfd443fe916c062548f30e91d88d26a3315995867ef5171ae65a59075a8f30
4
+ data.tar.gz: 349a834f109c1ab01cbf632ca042fa2850691c88ef77c61250c2d37d8e7def6b
5
5
  SHA512:
6
- metadata.gz: 1f168ec32386423f8c2beb49856791fed45ce11c80f42d3d7284d1782577b9c55e7f3344b13360ecbcc2b1bbf22932e919d1b79d5af05529fc40d353445581ea
7
- data.tar.gz: bf83a88519717178e2863c9ec85ef30acaeddb0dac5433ee0b467c4d2d7aa2f14d38ed657173903f74045a84446cb8e465002d7895d0ca69344cd780f72784ca
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 RSpec tests and checks whether the test suite catches the change. If a mutated
5
- test does not fail, that test is identified as weak.
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
- locates specs in `spec/`, applies mutations to covered code, and displays a summary:
29
- how many mutations were "killed" (caught by a test) and how many "survived" (no test
30
- caught them weak tests), with file, line, and related test(s) for each survivor.
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.spec_files.empty?
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
- test_suite = build_test_suite(config, detection)
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
- def build_test_suite(config, detection)
61
- test_runner = TestRunner.new(config: config, project_type: detection.project_type)
62
- baseline_examples = test_runner.run_baseline(detection.spec_files)
63
- TestSuite.from_baseline(project_type: detection.project_type, baseline_examples: baseline_examples)
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
@@ -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
- unless VALID_STRICTNESS.include?(strictness)
98
- raise ConfigError, "strictness must be one of #{VALID_STRICTNESS.join(', ')}, got #{strictness.inspect}"
99
- end
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))
@@ -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
@@ -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
- source_files.each do |file|
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.start_with?(File.join(config.target_dir, "spec")) }
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 spec file exists. Upgrade path: integrate SimpleCov
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 = mapped_spec_file(source_file)
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 mapped_spec_file(source_file)
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
- spec_relative = relative.sub(/\.rb\z/, "_spec.rb")
145
- File.join(config.target_dir, "spec", spec_relative)
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 and locates its RSpec
5
- # files (FR-001, FR-002).
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, :spec_files)
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
- Detection.new(project_type, discover_spec_files)
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
- def discover_spec_files
28
- pattern = File.join(config.target_dir, "spec", "**", "*_spec.rb")
29
- apply_scope(Dir.glob(pattern))
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)
@@ -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
@@ -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 RSpec suite as a subprocess (research.md #2):
9
- # MutateRB never bundles its own RSpec, it shells out to `bundle exec rspec`
10
- # inside the target project so the project's own Gemfile.lock decides the
11
- # RSpec version.
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
- def initialize(config:, project_type:)
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 spec files once, unmutated, and returns one Hash per
21
- # RSpec example: { id:, description:, file_path:, status:, duration: }.
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(spec_files)
25
- spawn_rspec(spec_files, timeout_seconds: nil).fetch(:examples, [])
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
- spawn_rspec(files, timeout_seconds: timeout_seconds)
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
- # making `bundle exec rspec` resolve the target project's suite against
49
- # the wrong bundle. with_unbundled_env restores the pre-bundler
50
- # environment for the duration of the spawn.
51
- def spawn_rspec(files, timeout_seconds:)
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, *rspec_command(files), out: stdout_write, err: File::NULL,
56
- chdir: File.expand_path(config.target_dir))
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
- parse_output(raw)
87
+ adapter.parse(raw, files: files)
80
88
  end
81
89
 
82
- # Kills a hung child process: TERM first, KILL if it ignores TERM for 1s
83
- # (research.md #3 — Timeout alone never touches the child process).
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
@@ -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 = :rspec
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MutateRB
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
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.1.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