henitai 0.1.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 +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE +21 -0
- data/README.md +182 -0
- data/assets/schema/henitai.schema.json +123 -0
- data/exe/henitai +6 -0
- data/lib/henitai/arid_node_filter.rb +97 -0
- data/lib/henitai/cli.rb +341 -0
- data/lib/henitai/configuration.rb +132 -0
- data/lib/henitai/configuration_validator.rb +293 -0
- data/lib/henitai/coverage_bootstrapper.rb +75 -0
- data/lib/henitai/coverage_formatter.rb +112 -0
- data/lib/henitai/equivalence_detector.rb +85 -0
- data/lib/henitai/execution_engine.rb +174 -0
- data/lib/henitai/git_diff_analyzer.rb +82 -0
- data/lib/henitai/integration.rb +417 -0
- data/lib/henitai/mutant/activator.rb +234 -0
- data/lib/henitai/mutant.rb +68 -0
- data/lib/henitai/mutant_generator.rb +158 -0
- data/lib/henitai/mutant_history_store.rb +279 -0
- data/lib/henitai/operator.rb +96 -0
- data/lib/henitai/operators/arithmetic_operator.rb +46 -0
- data/lib/henitai/operators/array_declaration.rb +52 -0
- data/lib/henitai/operators/assignment_expression.rb +78 -0
- data/lib/henitai/operators/block_statement.rb +31 -0
- data/lib/henitai/operators/boolean_literal.rb +70 -0
- data/lib/henitai/operators/conditional_expression.rb +184 -0
- data/lib/henitai/operators/equality_operator.rb +41 -0
- data/lib/henitai/operators/hash_literal.rb +66 -0
- data/lib/henitai/operators/logical_operator.rb +84 -0
- data/lib/henitai/operators/method_expression.rb +56 -0
- data/lib/henitai/operators/pattern_match.rb +66 -0
- data/lib/henitai/operators/range_literal.rb +40 -0
- data/lib/henitai/operators/return_value.rb +105 -0
- data/lib/henitai/operators/safe_navigation.rb +34 -0
- data/lib/henitai/operators/string_literal.rb +64 -0
- data/lib/henitai/operators.rb +25 -0
- data/lib/henitai/parser_current.rb +7 -0
- data/lib/henitai/reporter.rb +432 -0
- data/lib/henitai/result.rb +170 -0
- data/lib/henitai/runner.rb +183 -0
- data/lib/henitai/sampling_strategy.rb +33 -0
- data/lib/henitai/scenario_execution_result.rb +71 -0
- data/lib/henitai/source_parser.rb +41 -0
- data/lib/henitai/static_filter.rb +186 -0
- data/lib/henitai/stillborn_filter.rb +34 -0
- data/lib/henitai/subject.rb +71 -0
- data/lib/henitai/subject_resolver.rb +232 -0
- data/lib/henitai/syntax_validator.rb +16 -0
- data/lib/henitai/test_prioritizer.rb +55 -0
- data/lib/henitai/unparse_helper.rb +24 -0
- data/lib/henitai/version.rb +5 -0
- data/lib/henitai/warning_silencer.rb +16 -0
- data/lib/henitai.rb +51 -0
- data/sig/configuration_validator.rbs +29 -0
- data/sig/henitai.rbs +594 -0
- data/sig/unparser.rbs +3 -0
- metadata +153 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
# Runs pending mutants through the selected integration.
|
|
7
|
+
class ExecutionEngine
|
|
8
|
+
def run(mutants, integration, config, progress_reporter: nil)
|
|
9
|
+
with_reports_dir(config) do
|
|
10
|
+
with_coverage_dir(config) do
|
|
11
|
+
@flaky_retry_count = 0
|
|
12
|
+
pending_mutants = Array(mutants).select(&:pending?)
|
|
13
|
+
mutex = Mutex.new
|
|
14
|
+
if parallel_execution?(config, pending_mutants)
|
|
15
|
+
run_parallel(pending_mutants, integration, config, progress_reporter, mutex)
|
|
16
|
+
else
|
|
17
|
+
run_linear(pending_mutants, integration, config, progress_reporter, mutex)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
warn_flaky_mutants(pending_mutants.size)
|
|
21
|
+
mutants
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def parallel_execution?(config, mutants)
|
|
29
|
+
worker_count(config) > 1 && mutants.size > 1
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def worker_count(config)
|
|
33
|
+
configured_jobs = config.respond_to?(:jobs) ? config.jobs : nil
|
|
34
|
+
configured_jobs || Etc.nprocessors
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def run_linear(mutants, integration, config, progress_reporter, mutex)
|
|
38
|
+
mutants.each do |mutant|
|
|
39
|
+
process_mutant(mutant, integration, config, progress_reporter, mutex)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def run_parallel(mutants, integration, config, progress_reporter, mutex)
|
|
44
|
+
queue = Queue.new
|
|
45
|
+
mutants.each { |mutant| queue << mutant }
|
|
46
|
+
|
|
47
|
+
Array.new(worker_count(config)) do
|
|
48
|
+
Thread.new do
|
|
49
|
+
loop do
|
|
50
|
+
mutant = queue.pop(true)
|
|
51
|
+
process_mutant(mutant, integration, config, progress_reporter, mutex)
|
|
52
|
+
rescue ThreadError
|
|
53
|
+
break
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end.each(&:join)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def process_mutant(mutant, integration, config, progress_reporter, mutex)
|
|
60
|
+
test_files = prioritized_tests_for(mutant, integration, config)
|
|
61
|
+
scenario_result = run_with_flaky_retry(mutant, integration, config, test_files, mutex)
|
|
62
|
+
mutant.status = scenario_status(scenario_result)
|
|
63
|
+
|
|
64
|
+
if mutex
|
|
65
|
+
mutex.synchronize { progress_reporter&.progress(mutant, scenario_result:) }
|
|
66
|
+
else
|
|
67
|
+
progress_reporter&.progress(mutant, scenario_result:)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def prioritized_tests_for(mutant, integration, config)
|
|
72
|
+
test_prioritizer.sort(
|
|
73
|
+
integration.select_tests(mutant.subject),
|
|
74
|
+
mutant,
|
|
75
|
+
test_history(config)
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def test_prioritizer
|
|
80
|
+
@test_prioritizer ||= TestPrioritizer.new
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def test_history(config)
|
|
84
|
+
return {} unless config.respond_to?(:history)
|
|
85
|
+
|
|
86
|
+
config.history || {}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Retry logic is kept in one place to preserve the status transition flow.
|
|
90
|
+
# The retry budget is configurable because repeated survivors can multiply
|
|
91
|
+
# runtime on real CI workloads.
|
|
92
|
+
# rubocop:disable Metrics/MethodLength
|
|
93
|
+
def run_with_flaky_retry(mutant, integration, config, test_files, mutex)
|
|
94
|
+
scenario_result = integration.run_mutant(
|
|
95
|
+
mutant:,
|
|
96
|
+
test_files:,
|
|
97
|
+
timeout: config.timeout
|
|
98
|
+
)
|
|
99
|
+
return scenario_result unless scenario_status(scenario_result) == :survived
|
|
100
|
+
|
|
101
|
+
retries = 0
|
|
102
|
+
max_flaky_retries(config).times do
|
|
103
|
+
retries += 1
|
|
104
|
+
scenario_result = integration.run_mutant(
|
|
105
|
+
mutant:,
|
|
106
|
+
test_files:,
|
|
107
|
+
timeout: config.timeout
|
|
108
|
+
)
|
|
109
|
+
break unless scenario_status(scenario_result) == :survived
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
mutex.synchronize { @flaky_retry_count += 1 } if retries.positive?
|
|
113
|
+
scenario_result
|
|
114
|
+
end
|
|
115
|
+
# rubocop:enable Metrics/MethodLength
|
|
116
|
+
|
|
117
|
+
def scenario_status(result)
|
|
118
|
+
return result if result.is_a?(Symbol)
|
|
119
|
+
|
|
120
|
+
result.status
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def warn_flaky_mutants(total_mutants)
|
|
124
|
+
return if total_mutants.zero?
|
|
125
|
+
|
|
126
|
+
flaky_ratio = @flaky_retry_count.to_f / total_mutants
|
|
127
|
+
return unless flaky_ratio > 0.05
|
|
128
|
+
|
|
129
|
+
warn format(
|
|
130
|
+
"Flaky-test mitigation: %<flaky>d/%<total>d mutants required retries (%<ratio>.2f%%)",
|
|
131
|
+
flaky: @flaky_retry_count,
|
|
132
|
+
total: total_mutants,
|
|
133
|
+
ratio: flaky_ratio * 100.0
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def with_reports_dir(config)
|
|
138
|
+
original_reports_dir = ENV.fetch("HENITAI_REPORTS_DIR", nil)
|
|
139
|
+
ENV["HENITAI_REPORTS_DIR"] = config.reports_dir
|
|
140
|
+
yield
|
|
141
|
+
ensure
|
|
142
|
+
if original_reports_dir.nil?
|
|
143
|
+
ENV.delete("HENITAI_REPORTS_DIR")
|
|
144
|
+
else
|
|
145
|
+
ENV["HENITAI_REPORTS_DIR"] = original_reports_dir
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def with_coverage_dir(config)
|
|
150
|
+
original_coverage_dir = ENV.fetch("HENITAI_COVERAGE_DIR", nil)
|
|
151
|
+
ENV["HENITAI_COVERAGE_DIR"] = mutation_coverage_dir(config)
|
|
152
|
+
yield
|
|
153
|
+
ensure
|
|
154
|
+
if original_coverage_dir.nil?
|
|
155
|
+
ENV.delete("HENITAI_COVERAGE_DIR")
|
|
156
|
+
else
|
|
157
|
+
ENV["HENITAI_COVERAGE_DIR"] = original_coverage_dir
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def mutation_coverage_dir(config)
|
|
162
|
+
base_dir = config.respond_to?(:reports_dir) ? config.reports_dir : nil
|
|
163
|
+
base_dir = "reports" if base_dir.nil? || base_dir.empty?
|
|
164
|
+
|
|
165
|
+
File.join(base_dir, "mutation-coverage")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def max_flaky_retries(config)
|
|
169
|
+
return 3 unless config.respond_to?(:max_flaky_retries)
|
|
170
|
+
|
|
171
|
+
config.max_flaky_retries || 3
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require_relative "subject_resolver"
|
|
5
|
+
|
|
6
|
+
module Henitai
|
|
7
|
+
class GitDiffError < StandardError; end
|
|
8
|
+
|
|
9
|
+
# Shells out to git to discover changed files between two refs.
|
|
10
|
+
#
|
|
11
|
+
# By default the analyzer runs in the current working directory. Callers can
|
|
12
|
+
# pass dir: to point it at another repository root without changing cwd.
|
|
13
|
+
class GitDiffAnalyzer
|
|
14
|
+
def changed_files(from:, to:, dir: Dir.pwd)
|
|
15
|
+
stdout, stderr, status = git_diff(dir, "--name-only", from, to)
|
|
16
|
+
|
|
17
|
+
raise GitDiffError, stderr.strip unless status.success?
|
|
18
|
+
|
|
19
|
+
stdout.split("\n").reject(&:empty?)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def changed_methods(from:, to:, dir: Dir.pwd)
|
|
23
|
+
changed_files(from:, to:, dir:).flat_map do |path|
|
|
24
|
+
changed_methods_in_file(path, from:, to:, dir:)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def changed_methods_in_file(path, from:, to:, dir:)
|
|
31
|
+
subjects = SubjectResolver.new.resolve_from_files([File.expand_path(path, dir)])
|
|
32
|
+
changed_ranges = changed_line_ranges(path, from:, to:, dir:)
|
|
33
|
+
|
|
34
|
+
subjects.select do |subject|
|
|
35
|
+
subject.source_range &&
|
|
36
|
+
changed_ranges.any? do |range|
|
|
37
|
+
ranges_overlap?(subject.source_range, range)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def changed_line_ranges(path, from:, to:, dir:)
|
|
43
|
+
stdout, stderr, status = git_diff(dir, "--unified=0", from, to, "--", path)
|
|
44
|
+
|
|
45
|
+
raise GitDiffError, stderr.strip unless status.success?
|
|
46
|
+
|
|
47
|
+
stdout.each_line.filter_map { |line| changed_range_from_hunk(line) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def changed_range_from_hunk(line)
|
|
51
|
+
match = line.match(/\A@@ -\d+(?:,\d+)? \+(?<start>\d+)(?:,(?<count>\d+))? @@/)
|
|
52
|
+
return unless match
|
|
53
|
+
|
|
54
|
+
start_line = match[:start].to_i
|
|
55
|
+
line_count = hunk_line_count(match)
|
|
56
|
+
|
|
57
|
+
start_line..(start_line + line_count - 1)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def hunk_line_count(match)
|
|
61
|
+
line_count = match[:count].nil? ? 1 : match[:count].to_i
|
|
62
|
+
# Git uses `+start` for a one-line hunk and `+start,0` for a pure
|
|
63
|
+
# deletion. We still anchor both at the reported start line so the
|
|
64
|
+
# current subject range can absorb the change point.
|
|
65
|
+
line_count = 1 if line_count.zero?
|
|
66
|
+
line_count
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def ranges_overlap?(left, right)
|
|
70
|
+
left.begin <= right.end && right.begin <= left.end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def git_diff(dir, *git_args)
|
|
74
|
+
command = ["git"]
|
|
75
|
+
command += ["-C", dir] if dir
|
|
76
|
+
command << "diff"
|
|
77
|
+
command.concat(git_args)
|
|
78
|
+
|
|
79
|
+
Open3.capture3(*command)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "minitest"
|
|
5
|
+
require "rspec/core"
|
|
6
|
+
|
|
7
|
+
module Henitai
|
|
8
|
+
# Namespace for test-framework integrations.
|
|
9
|
+
#
|
|
10
|
+
# An Integration is responsible for:
|
|
11
|
+
# 1. Discovering test files relevant to a Subject (test selection)
|
|
12
|
+
# 2. Running the selected tests in a child process with a mutant injected
|
|
13
|
+
# 3. Reporting pass/fail/timeout to the runner
|
|
14
|
+
#
|
|
15
|
+
# Test selection uses longest-prefix matching:
|
|
16
|
+
# Subject expression "Foo::Bar#method" matches example groups whose
|
|
17
|
+
# description contains "Foo::Bar" or "Foo::Bar#method".
|
|
18
|
+
#
|
|
19
|
+
# Built-in integrations:
|
|
20
|
+
# rspec — RSpec 3.x
|
|
21
|
+
module Integration
|
|
22
|
+
# Shared helpers for capturing stdout/stderr from child test processes.
|
|
23
|
+
class ScenarioLogSupport
|
|
24
|
+
def capture_child_output(log_paths)
|
|
25
|
+
output_files = open_child_output(log_paths)
|
|
26
|
+
yield
|
|
27
|
+
ensure
|
|
28
|
+
close_child_output(output_files)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def with_coverage_dir(mutant_id)
|
|
32
|
+
original_coverage_dir = ENV.fetch("HENITAI_COVERAGE_DIR", nil)
|
|
33
|
+
ENV["HENITAI_COVERAGE_DIR"] = mutation_coverage_dir(mutant_id)
|
|
34
|
+
yield
|
|
35
|
+
ensure
|
|
36
|
+
if original_coverage_dir.nil?
|
|
37
|
+
ENV.delete("HENITAI_COVERAGE_DIR")
|
|
38
|
+
else
|
|
39
|
+
ENV["HENITAI_COVERAGE_DIR"] = original_coverage_dir
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def open_child_output(log_paths)
|
|
44
|
+
FileUtils.mkdir_p(File.dirname(log_paths[:log_path]))
|
|
45
|
+
output_files = build_child_output_files(log_paths)
|
|
46
|
+
sync_child_output_files(output_files)
|
|
47
|
+
redirect_child_output(output_files)
|
|
48
|
+
output_files
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def close_child_output(output_files)
|
|
52
|
+
return unless output_files
|
|
53
|
+
|
|
54
|
+
restore_child_output(output_files)
|
|
55
|
+
close_child_output_files(output_files)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def build_child_output_files(log_paths)
|
|
59
|
+
{
|
|
60
|
+
original_stdout: $stdout.dup,
|
|
61
|
+
original_stderr: $stderr.dup,
|
|
62
|
+
stdout_file: File.new(log_paths[:stdout_path], "w"),
|
|
63
|
+
stderr_file: File.new(log_paths[:stderr_path], "w")
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def sync_child_output_files(output_files)
|
|
68
|
+
output_files[:stdout_file].sync = true
|
|
69
|
+
output_files[:stderr_file].sync = true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def redirect_child_output(output_files)
|
|
73
|
+
$stdout.reopen(output_files[:stdout_file])
|
|
74
|
+
$stderr.reopen(output_files[:stderr_file])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def restore_child_output(output_files)
|
|
78
|
+
reopen_child_output_stream($stdout, output_files[:original_stdout])
|
|
79
|
+
reopen_child_output_stream($stderr, output_files[:original_stderr])
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def reopen_child_output_stream(stream, original_stream)
|
|
83
|
+
stream.reopen(original_stream) if original_stream
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def close_child_output_files(output_files)
|
|
87
|
+
%i[stdout_file stderr_file original_stdout original_stderr].each do |key|
|
|
88
|
+
output_files[key]&.close
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def mutation_coverage_dir(mutant_id)
|
|
95
|
+
reports_dir = ENV.fetch("HENITAI_REPORTS_DIR", "reports")
|
|
96
|
+
File.join(reports_dir, "mutation-coverage", mutant_id.to_s)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Integration adapter for RSpec.
|
|
101
|
+
#
|
|
102
|
+
# This class exists as the stable public entry point for the RSpec
|
|
103
|
+
# integration, even though the concrete behavior is not implemented yet.
|
|
104
|
+
# @param name [String] integration name, e.g. "rspec"
|
|
105
|
+
# @return [Class] integration class
|
|
106
|
+
def self.for(name)
|
|
107
|
+
const_get(name.capitalize)
|
|
108
|
+
rescue NameError
|
|
109
|
+
raise ArgumentError, "Unknown integration: #{name}. Available: rspec"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Base class for all integrations.
|
|
113
|
+
class Base
|
|
114
|
+
# @param subject [Subject]
|
|
115
|
+
# @return [Array<String>] paths to test files that cover this subject
|
|
116
|
+
def select_tests(subject)
|
|
117
|
+
raise NotImplementedError
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @return [Array<String>] all test files for the configured framework
|
|
121
|
+
def test_files
|
|
122
|
+
raise NotImplementedError
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Run test files in a child process with the mutant active.
|
|
126
|
+
#
|
|
127
|
+
# @param mutant [Mutant]
|
|
128
|
+
# @param test_files [Array<String>]
|
|
129
|
+
# @param timeout [Float] seconds
|
|
130
|
+
# @return [ScenarioExecutionResult]
|
|
131
|
+
def run_mutant(mutant:, test_files:, timeout:)
|
|
132
|
+
raise NotImplementedError
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# RSpec integration adapter.
|
|
137
|
+
class Rspec < Base
|
|
138
|
+
DEFAULT_SUITE_TIMEOUT = 300.0
|
|
139
|
+
REQUIRE_DIRECTIVE_PATTERN = /
|
|
140
|
+
\A\s*
|
|
141
|
+
(require|require_relative)
|
|
142
|
+
\s*
|
|
143
|
+
(?:\(\s*)?
|
|
144
|
+
["']([^"']+)["']
|
|
145
|
+
\s*\)?
|
|
146
|
+
/x
|
|
147
|
+
|
|
148
|
+
def select_tests(subject)
|
|
149
|
+
matches = spec_files.select do |path|
|
|
150
|
+
content = File.read(path)
|
|
151
|
+
selection_patterns(subject).any? { |pattern| content.include?(pattern) }
|
|
152
|
+
rescue StandardError
|
|
153
|
+
false
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
return matches unless matches.empty?
|
|
157
|
+
|
|
158
|
+
fallback_spec_files(subject)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def test_files
|
|
162
|
+
spec_files
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def run_mutant(mutant:, test_files:, timeout:)
|
|
166
|
+
log_paths = scenario_log_paths("mutant-#{mutant.id}")
|
|
167
|
+
pid = Process.fork do
|
|
168
|
+
ENV["HENITAI_MUTANT_ID"] = mutant.id
|
|
169
|
+
Process.exit(run_in_child(mutant:, test_files:, log_paths:))
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
build_result(wait_with_timeout(pid, timeout), log_paths)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def run_suite(test_files, timeout: DEFAULT_SUITE_TIMEOUT)
|
|
176
|
+
log_paths = scenario_log_paths("baseline")
|
|
177
|
+
FileUtils.mkdir_p(File.dirname(log_paths[:stdout_path]))
|
|
178
|
+
pid = File.open(log_paths[:stdout_path], "w") do |stdout_file|
|
|
179
|
+
File.open(log_paths[:stderr_path], "w") do |stderr_file|
|
|
180
|
+
Process.spawn(*suite_command(test_files), out: stdout_file, err: stderr_file)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
build_result(wait_with_timeout(pid, timeout), log_paths)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
def run_in_child(mutant:, test_files:, log_paths:)
|
|
189
|
+
scenario_log_support.with_coverage_dir(mutant.id) do
|
|
190
|
+
scenario_log_support.capture_child_output(log_paths) do
|
|
191
|
+
return 2 if Mutant::Activator.activate!(mutant) == :compile_error
|
|
192
|
+
|
|
193
|
+
run_tests(test_files)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def suite_command(test_files)
|
|
199
|
+
["bundle", "exec", "rspec", *test_files]
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def wait_with_timeout(pid, timeout)
|
|
203
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
204
|
+
|
|
205
|
+
loop do
|
|
206
|
+
return Process.last_status if Process.wait(pid, Process::WNOHANG)
|
|
207
|
+
return handle_timeout(pid) if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
208
|
+
|
|
209
|
+
pause(0.01)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def handle_timeout(pid)
|
|
214
|
+
begin
|
|
215
|
+
Process.kill(:SIGTERM, pid)
|
|
216
|
+
pause(2.0)
|
|
217
|
+
Process.kill(:SIGKILL, pid)
|
|
218
|
+
rescue Errno::ESRCH
|
|
219
|
+
# The child may exit after SIGTERM but before SIGKILL.
|
|
220
|
+
ensure
|
|
221
|
+
reap_child(pid)
|
|
222
|
+
end
|
|
223
|
+
:timeout
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def run_tests(test_files)
|
|
227
|
+
status = RSpec::Core::Runner.run(test_files + rspec_options)
|
|
228
|
+
return status if status.is_a?(Integer)
|
|
229
|
+
|
|
230
|
+
status == true ? 0 : 1
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def rspec_options
|
|
234
|
+
["--require", "henitai/coverage_formatter"]
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def pause(seconds)
|
|
238
|
+
sleep(seconds)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def scenario_log_support
|
|
242
|
+
@scenario_log_support ||= ScenarioLogSupport.new
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def read_log_file(path)
|
|
246
|
+
return "" unless File.exist?(path)
|
|
247
|
+
|
|
248
|
+
File.read(path)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def write_combined_log(path, stdout, stderr)
|
|
252
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
253
|
+
File.write(path, combined_log(stdout, stderr))
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def combined_log(stdout, stderr)
|
|
257
|
+
[
|
|
258
|
+
(stdout.empty? ? nil : "stdout:\n#{stdout}"),
|
|
259
|
+
(stderr.empty? ? nil : "stderr:\n#{stderr}")
|
|
260
|
+
].compact.join("\n")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def scenario_log_paths(name)
|
|
264
|
+
reports_dir = ENV.fetch("HENITAI_REPORTS_DIR", "reports")
|
|
265
|
+
log_dir = File.join(reports_dir, "mutation-logs")
|
|
266
|
+
{
|
|
267
|
+
stdout_path: File.join(log_dir, "#{name}.stdout.log"),
|
|
268
|
+
stderr_path: File.join(log_dir, "#{name}.stderr.log"),
|
|
269
|
+
log_path: File.join(log_dir, "#{name}.log")
|
|
270
|
+
}
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def build_result(wait_result, log_paths)
|
|
274
|
+
status = scenario_status(wait_result)
|
|
275
|
+
stdout = read_log_file(log_paths[:stdout_path])
|
|
276
|
+
stderr = read_log_file(log_paths[:stderr_path])
|
|
277
|
+
write_combined_log(log_paths[:log_path], stdout, stderr)
|
|
278
|
+
|
|
279
|
+
ScenarioExecutionResult.new(
|
|
280
|
+
status:,
|
|
281
|
+
stdout:,
|
|
282
|
+
stderr:,
|
|
283
|
+
log_path: log_paths[:log_path],
|
|
284
|
+
exit_status: exit_status_for(wait_result)
|
|
285
|
+
)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def scenario_status(wait_result)
|
|
289
|
+
return :timeout if wait_result == :timeout
|
|
290
|
+
return :compile_error if exit_status_for(wait_result) == 2
|
|
291
|
+
return :survived if wait_result.respond_to?(:success?) && wait_result.success?
|
|
292
|
+
|
|
293
|
+
:killed
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def exit_status_for(wait_result)
|
|
297
|
+
return nil if wait_result == :timeout
|
|
298
|
+
return nil unless wait_result.respond_to?(:exitstatus)
|
|
299
|
+
|
|
300
|
+
wait_result.exitstatus
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def spec_files
|
|
304
|
+
Dir.glob("spec/**/*_spec.rb")
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def fallback_spec_files(subject)
|
|
308
|
+
return [] unless subject.source_file
|
|
309
|
+
|
|
310
|
+
matches = spec_files.select do |path|
|
|
311
|
+
requires_source_file_transitively?(path, subject.source_file)
|
|
312
|
+
rescue StandardError
|
|
313
|
+
false
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
matches.empty? ? spec_files : matches
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def selection_patterns(subject)
|
|
320
|
+
[
|
|
321
|
+
subject.expression,
|
|
322
|
+
subject.namespace
|
|
323
|
+
].compact.uniq.sort_by(&:length).reverse
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def requires_source_file?(spec_file, source_file)
|
|
327
|
+
content = File.read(spec_file)
|
|
328
|
+
basename = File.basename(source_file, ".rb")
|
|
329
|
+
content.include?(basename) || content.include?(source_file)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def requires_source_file_transitively?(spec_file, source_file, visited = [])
|
|
333
|
+
normalized_spec_file = File.expand_path(spec_file)
|
|
334
|
+
return false if visited.include?(normalized_spec_file)
|
|
335
|
+
|
|
336
|
+
visited << normalized_spec_file
|
|
337
|
+
return true if requires_source_file?(spec_file, source_file)
|
|
338
|
+
|
|
339
|
+
required_files(spec_file).any? do |required_file|
|
|
340
|
+
requires_source_file_transitively?(required_file, source_file, visited)
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def required_files(spec_file)
|
|
345
|
+
File.read(spec_file).lines.filter_map do |line|
|
|
346
|
+
match = line.match(REQUIRE_DIRECTIVE_PATTERN)
|
|
347
|
+
next unless match
|
|
348
|
+
|
|
349
|
+
resolve_required_file(spec_file, match[1].to_s, match[2].to_s)
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def resolve_required_file(spec_file, method_name, required_path)
|
|
354
|
+
candidates =
|
|
355
|
+
if method_name == "require_relative"
|
|
356
|
+
relative_candidates(spec_file, required_path)
|
|
357
|
+
else
|
|
358
|
+
require_candidates(spec_file, required_path)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
candidates.find { |candidate| File.file?(candidate) }
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def relative_candidates(spec_file, required_path)
|
|
365
|
+
expand_candidates(File.dirname(spec_file), required_path)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def require_candidates(spec_file, required_path)
|
|
369
|
+
([File.dirname(spec_file), Dir.pwd] + $LOAD_PATH).flat_map do |base_path|
|
|
370
|
+
expand_candidates(base_path, required_path)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def expand_candidates(base_path, required_path)
|
|
375
|
+
[
|
|
376
|
+
File.expand_path(required_path, base_path),
|
|
377
|
+
File.expand_path("#{required_path}.rb", base_path)
|
|
378
|
+
].uniq
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def reap_child(pid)
|
|
382
|
+
Process.wait(pid)
|
|
383
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
384
|
+
nil
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Minitest integration adapter.
|
|
389
|
+
#
|
|
390
|
+
# Coverage formatter injection remains implemented in the RSpec child
|
|
391
|
+
# runner. Minitest shares selection and execution semantics, but per-test
|
|
392
|
+
# coverage collection is not yet wired into this path.
|
|
393
|
+
class Minitest < Rspec
|
|
394
|
+
private
|
|
395
|
+
|
|
396
|
+
def suite_command(test_files)
|
|
397
|
+
["bundle", "exec", "ruby", "-I", "test",
|
|
398
|
+
"-e", "ARGV.each { |f| require File.expand_path(f) }",
|
|
399
|
+
*test_files]
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def run_tests(test_files)
|
|
403
|
+
test_files.each { |file| require File.expand_path(file) }
|
|
404
|
+
# @type var empty_args: Array[String]
|
|
405
|
+
empty_args = []
|
|
406
|
+
status = ::Minitest.run(empty_args)
|
|
407
|
+
return status if status.is_a?(Integer)
|
|
408
|
+
|
|
409
|
+
status == true ? 0 : 1
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def spec_files
|
|
413
|
+
Dir.glob("test/**/*_test.rb") + Dir.glob("test/**/*_spec.rb")
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
end
|