henitai 0.1.10 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +94 -1
- data/README.md +33 -7
- data/assets/schema/henitai.schema.json +6 -0
- data/lib/henitai/cli/clean_command.rb +48 -0
- data/lib/henitai/cli/command_support.rb +51 -0
- data/lib/henitai/cli/init_command.rb +64 -0
- data/lib/henitai/cli/operator_command.rb +95 -0
- data/lib/henitai/cli/options.rb +120 -0
- data/lib/henitai/cli/run_command.rb +103 -0
- data/lib/henitai/cli.rb +17 -327
- data/lib/henitai/configuration.rb +26 -12
- data/lib/henitai/configuration_validator/rules.rb +143 -0
- data/lib/henitai/configuration_validator/scalars.rb +123 -0
- data/lib/henitai/configuration_validator.rb +12 -239
- data/lib/henitai/coverage_bootstrapper.rb +24 -24
- data/lib/henitai/eager_load.rb +36 -5
- data/lib/henitai/execution_engine.rb +6 -11
- data/lib/henitai/git_diff_analyzer.rb +34 -0
- data/lib/henitai/integration/base.rb +171 -0
- data/lib/henitai/integration/child_debug_support.rb +115 -0
- data/lib/henitai/integration/child_runtime_control.rb +50 -0
- data/lib/henitai/integration/coverage_suppression.rb +43 -0
- data/lib/henitai/integration/minitest.rb +133 -0
- data/lib/henitai/integration/mutant_run_support.rb +77 -0
- data/lib/henitai/integration/rspec_child_runner.rb +61 -0
- data/lib/henitai/integration/rspec_process_runner.rb +66 -13
- data/lib/henitai/integration/rspec_test_selection.rb +135 -0
- data/lib/henitai/integration/scenario_log_support.rb +116 -0
- data/lib/henitai/integration.rb +43 -519
- data/lib/henitai/mutant/activator.rb +13 -79
- data/lib/henitai/mutant/parameter_source.rb +98 -0
- data/lib/henitai/mutant.rb +14 -2
- data/lib/henitai/mutant_generator.rb +21 -2
- data/lib/henitai/mutant_history_store/sql.rb +72 -0
- data/lib/henitai/mutant_history_store.rb +12 -91
- data/lib/henitai/mutant_identity.rb +34 -0
- data/lib/henitai/parallel_execution_runner.rb +29 -11
- data/lib/henitai/per_test_coverage_collector.rb +3 -1
- data/lib/henitai/process_wakeup.rb +49 -0
- data/lib/henitai/process_worker_runner.rb +148 -0
- data/lib/henitai/reporter.rb +96 -11
- data/lib/henitai/result.rb +49 -16
- data/lib/henitai/runner.rb +96 -30
- data/lib/henitai/scenario_execution_result.rb +16 -3
- data/lib/henitai/slot_scheduler/draining.rb +140 -0
- data/lib/henitai/slot_scheduler/process_control.rb +43 -0
- data/lib/henitai/slot_scheduler.rb +214 -0
- data/lib/henitai/static_filter.rb +10 -3
- data/lib/henitai/survivor_activation_cache.rb +81 -0
- data/lib/henitai/survivor_loader.rb +140 -0
- data/lib/henitai/survivor_rerun_strategy.rb +195 -0
- data/lib/henitai/survivor_selector.rb +36 -0
- data/lib/henitai/survivor_test_filter.rb +72 -0
- data/lib/henitai/unparse_helper.rb +5 -2
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +10 -0
- data/sig/configuration_validator.rbs +46 -22
- data/sig/henitai.rbs +329 -53
- metadata +46 -2
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require_relative "../process_wakeup"
|
|
5
|
+
require_relative "child_debug_support"
|
|
6
|
+
require_relative "child_runtime_control"
|
|
7
|
+
require_relative "scenario_log_support"
|
|
8
|
+
|
|
9
|
+
module Henitai
|
|
10
|
+
module Integration
|
|
11
|
+
# Base class for all integrations. Provides the framework-agnostic child
|
|
12
|
+
# process lifecycle (wait, timeout handling, cleanup) and subprocess
|
|
13
|
+
# environment helpers. Concrete adapters mix in MutantRunSupport and
|
|
14
|
+
# implement #run_tests plus test selection.
|
|
15
|
+
class Base
|
|
16
|
+
include ChildDebugSupport
|
|
17
|
+
include ChildRuntimeControl
|
|
18
|
+
|
|
19
|
+
# @param subject [Subject]
|
|
20
|
+
# @return [Array<String>] paths to test files that cover this subject
|
|
21
|
+
def select_tests(subject)
|
|
22
|
+
raise NotImplementedError
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Array<String>] all test files for the configured framework
|
|
26
|
+
def test_files
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Run test files in a child process with the mutant active.
|
|
31
|
+
#
|
|
32
|
+
# @param mutant [Mutant]
|
|
33
|
+
# @param test_files [Array<String>]
|
|
34
|
+
# @param timeout [Float] seconds
|
|
35
|
+
# @return [ScenarioExecutionResult]
|
|
36
|
+
def run_mutant(mutant:, test_files:, timeout:)
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Fork a child process for the mutant without waiting for it to finish.
|
|
41
|
+
# Returns a ChildHandle carrying the OS pid and log file paths.
|
|
42
|
+
# The caller is responsible for waiting and cleanup.
|
|
43
|
+
#
|
|
44
|
+
# @param mutant [Mutant]
|
|
45
|
+
# @param test_files [Array<String>]
|
|
46
|
+
# @return [RspecProcessRunner::ChildHandle]
|
|
47
|
+
def spawn_mutant(mutant:, test_files:)
|
|
48
|
+
raise NotImplementedError
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def per_test_coverage_supported?
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def wait_with_timeout(pid, timeout)
|
|
56
|
+
wakeup = Henitai.const_get(:ProcessWakeup).new.install
|
|
57
|
+
return Process.last_status if wait_nonblocking(pid)
|
|
58
|
+
|
|
59
|
+
wakeup.wait(timeout)
|
|
60
|
+
wakeup.drain
|
|
61
|
+
return Process.last_status if wait_nonblocking(pid)
|
|
62
|
+
return Process.last_status if wait_nonblocking(pid)
|
|
63
|
+
|
|
64
|
+
handle_timeout(pid)
|
|
65
|
+
ensure
|
|
66
|
+
wakeup&.close
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def reap_child(pid)
|
|
70
|
+
Process.wait(pid)
|
|
71
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def cleanup_process_group(pid)
|
|
76
|
+
grace_period = 2.0
|
|
77
|
+
wakeup = Henitai.const_get(:ProcessWakeup).new.install
|
|
78
|
+
Process.kill(:SIGTERM, -pid)
|
|
79
|
+
return if wait_nonblocking(pid)
|
|
80
|
+
|
|
81
|
+
wakeup.wait(grace_period)
|
|
82
|
+
wakeup.drain
|
|
83
|
+
return if wait_nonblocking(pid)
|
|
84
|
+
|
|
85
|
+
Process.kill(:SIGKILL, -pid)
|
|
86
|
+
rescue Errno::EPERM
|
|
87
|
+
cleanup_child_process(pid)
|
|
88
|
+
rescue Errno::ESRCH
|
|
89
|
+
nil
|
|
90
|
+
ensure
|
|
91
|
+
wakeup&.close
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def pause(seconds)
|
|
97
|
+
sleep(seconds)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def handle_timeout(pid)
|
|
101
|
+
begin
|
|
102
|
+
debug_child_timeout_dump(pid)
|
|
103
|
+
cleanup_process_group(pid)
|
|
104
|
+
ensure
|
|
105
|
+
reap_child(pid)
|
|
106
|
+
end
|
|
107
|
+
:timeout
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def cleanup_child_process(pid)
|
|
111
|
+
grace_period = 2.0
|
|
112
|
+
wakeup = Henitai.const_get(:ProcessWakeup).new.install
|
|
113
|
+
Process.kill(:SIGTERM, pid)
|
|
114
|
+
return if wait_nonblocking(pid)
|
|
115
|
+
|
|
116
|
+
wakeup.wait(grace_period)
|
|
117
|
+
wakeup.drain
|
|
118
|
+
return if wait_nonblocking(pid)
|
|
119
|
+
|
|
120
|
+
Process.kill(:SIGKILL, pid)
|
|
121
|
+
rescue Errno::EPERM, Errno::ESRCH
|
|
122
|
+
nil
|
|
123
|
+
ensure
|
|
124
|
+
wakeup&.close
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def subprocess_env
|
|
128
|
+
{ "PARALLEL_WORKERS" => "1" }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def wait_nonblocking(pid)
|
|
132
|
+
Process.wait(pid, Process::WNOHANG)
|
|
133
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def scenario_log_support
|
|
138
|
+
@scenario_log_support ||= ScenarioLogSupport.new
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def with_subprocess_env
|
|
142
|
+
original_env = {} # : Hash[String, String?]
|
|
143
|
+
subprocess_env.each do |key, value|
|
|
144
|
+
original_env[key] = ENV.fetch(key, nil)
|
|
145
|
+
ENV[key] = value
|
|
146
|
+
end
|
|
147
|
+
yield
|
|
148
|
+
ensure
|
|
149
|
+
restore_subprocess_env(original_env)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def restore_subprocess_env(original_env)
|
|
153
|
+
original_env.each do |key, value|
|
|
154
|
+
if value.nil?
|
|
155
|
+
ENV.delete(key)
|
|
156
|
+
else
|
|
157
|
+
ENV[key] = value
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def with_non_interactive_stdin
|
|
163
|
+
original_stdin = $stdin
|
|
164
|
+
$stdin = StringIO.new
|
|
165
|
+
yield
|
|
166
|
+
ensure
|
|
167
|
+
$stdin = original_stdin
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
module Integration
|
|
5
|
+
# Shared debug helpers for child-run diagnostics.
|
|
6
|
+
# Debug helpers are intentionally grouped here so the child-run diagnostics
|
|
7
|
+
# stay isolated from the main integration flow.
|
|
8
|
+
module ChildDebugSupport
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def debug_child? = ENV["HENITAI_DEBUG_CHILD"] == "1"
|
|
12
|
+
|
|
13
|
+
def debug_child_puts(message)
|
|
14
|
+
$stdout.puts(message)
|
|
15
|
+
$stdout.flush
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def debug_child_rspec_trace(test_files:, rspec_options:, rspec_argv:)
|
|
19
|
+
return unless debug_child?
|
|
20
|
+
|
|
21
|
+
files_exist = test_files.map { |f| [f, File.exist?(f)] }.inspect
|
|
22
|
+
loaded_features = loaded_feature_map(test_files).inspect # steep:ignore Ruby::NoMethod
|
|
23
|
+
|
|
24
|
+
debug_child_puts(
|
|
25
|
+
"[henitai-debug-child] cwd=#{Dir.pwd}\n" \
|
|
26
|
+
"[henitai-debug-child] files_exist=#{files_exist}\n" \
|
|
27
|
+
"[henitai-debug-child] loaded_features_check=#{loaded_features}\n" \
|
|
28
|
+
"[henitai-debug-child] test_files=#{test_files.inspect}\n" \
|
|
29
|
+
"[henitai-debug-child] rspec_options=#{rspec_options.inspect}\n" \
|
|
30
|
+
"[henitai-debug-child] rspec_argv=#{rspec_argv.inspect}"
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def debug_child_rspec_exit(status)
|
|
35
|
+
return unless debug_child?
|
|
36
|
+
|
|
37
|
+
debug_child_puts("[henitai-debug-child] RSpec result=#{status.inspect}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def debug_child_example_count(stage) # steep:ignore Ruby::UndeclaredMethodDefinition
|
|
41
|
+
return unless debug_child?
|
|
42
|
+
|
|
43
|
+
count = rspec_world_example_count
|
|
44
|
+
debug_child_puts(
|
|
45
|
+
"[henitai-debug-child] rspec_world_example_count_#{stage}=#{count.inspect}"
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def debug_child_activation_start(mutant_id)
|
|
50
|
+
return unless debug_child?
|
|
51
|
+
|
|
52
|
+
debug_child_puts("[henitai-debug-child] activate_start mutant=#{mutant_id}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def debug_child_activation_end(activation_result, test_files:)
|
|
56
|
+
return unless debug_child?
|
|
57
|
+
|
|
58
|
+
debug_child_puts(
|
|
59
|
+
"[henitai-debug-child] activate_end result=#{activation_result.inspect}\n" \
|
|
60
|
+
"[henitai-debug-child] run_tests_start test_files=#{test_files.inspect}"
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def debug_child_mutant_meta(mutant)
|
|
65
|
+
stable_id = mutant.respond_to?(:stable_id) ? mutant.stable_id : nil
|
|
66
|
+
operator = mutant.respond_to?(:operator) ? mutant.operator : nil
|
|
67
|
+
has_subject_expression =
|
|
68
|
+
mutant.respond_to?(:subject) && mutant.subject.respond_to?(:expression)
|
|
69
|
+
subject_expression = has_subject_expression ? mutant.subject.expression : nil
|
|
70
|
+
location = mutant.respond_to?(:location) ? mutant.location.inspect : nil
|
|
71
|
+
|
|
72
|
+
debug_child_puts(
|
|
73
|
+
"[henitai-debug-child] mutant_meta stableId=#{stable_id}\n" \
|
|
74
|
+
"[henitai-debug-child] mutant_meta operator=#{operator}\n" \
|
|
75
|
+
"[henitai-debug-child] mutant_meta subject=#{subject_expression}\n" \
|
|
76
|
+
"[henitai-debug-child] mutant_meta location=#{location}\n"
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def debug_child_activation_check
|
|
81
|
+
location = begin
|
|
82
|
+
Henitai::Runner.instance_method(:resolve_subjects).source_location&.join(":")
|
|
83
|
+
rescue StandardError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
debug_child_puts(
|
|
88
|
+
"[henitai-debug-child] activation_check resolve_subjects_location=#{location}\n"
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def loaded_feature_map(test_files) = test_files.map { |file| [file, loaded_feature?(file)] } # steep:ignore Ruby::UndeclaredMethodDefinition
|
|
93
|
+
|
|
94
|
+
def loaded_feature?(file) # steep:ignore Ruby::UndeclaredMethodDefinition
|
|
95
|
+
expanded = File.expand_path(file)
|
|
96
|
+
candidates = [expanded, "#{expanded}.rb", file, "#{file}.rb"].uniq
|
|
97
|
+
$LOADED_FEATURES.any? do |feature|
|
|
98
|
+
normalized = begin
|
|
99
|
+
File.expand_path(feature)
|
|
100
|
+
rescue StandardError
|
|
101
|
+
feature
|
|
102
|
+
end
|
|
103
|
+
candidates.include?(feature) || candidates.include?(normalized)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def rspec_world_example_count # steep:ignore Ruby::UndeclaredMethodDefinition
|
|
108
|
+
world = ::RSpec.__send__(:world)
|
|
109
|
+
world.example_count
|
|
110
|
+
rescue StandardError
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "coverage_suppression"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
module Integration
|
|
7
|
+
# Runtime controls applied inside the mutant child process: coverage
|
|
8
|
+
# suppression and the diagnostic timeout/thread-dump signal handling.
|
|
9
|
+
module ChildRuntimeControl
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def suppress_simplecov!
|
|
13
|
+
CoverageRuntimeSuppressors.suppress_simplecov!
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def suppress_coverage!
|
|
17
|
+
CoverageRuntimeSuppressors.suppress_coverage!
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def debug_child_timeout_dump(pid)
|
|
21
|
+
return unless debug_child?
|
|
22
|
+
|
|
23
|
+
debug_child_puts("[henitai-debug-child] timeout_signal_sent pid=#{pid}")
|
|
24
|
+
Process.kill(:USR1, pid)
|
|
25
|
+
pause(0.2)
|
|
26
|
+
rescue Errno::ESRCH
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def install_debug_timeout_trap
|
|
31
|
+
Signal.trap("USR1") { debug_child_thread_dump("timeout") }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def debug_child_thread_dump(reason)
|
|
35
|
+
return unless debug_child?
|
|
36
|
+
|
|
37
|
+
debug_child_puts("[henitai-debug-child] thread_dump reason=#{reason}")
|
|
38
|
+
Thread.list.each_with_index do |thread, index|
|
|
39
|
+
debug_child_puts(
|
|
40
|
+
"[henitai-debug-child] thread index=#{index} id=#{thread.object_id} " \
|
|
41
|
+
"status=#{thread.status.inspect}"
|
|
42
|
+
)
|
|
43
|
+
Array(thread.backtrace).each do |line|
|
|
44
|
+
debug_child_puts("[henitai-debug-child] #{line}")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
module Integration
|
|
5
|
+
# Prepended onto SimpleCov's singleton class to turn start into a no-op
|
|
6
|
+
# during mutant child runs. Using prepend avoids "method redefined" warnings.
|
|
7
|
+
module SimpleCovStartSuppressor
|
|
8
|
+
def start(*_args) = nil
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Prepended onto the coverage gem's Coverage singleton to turn start
|
|
12
|
+
# into a no-op during mutant child runs.
|
|
13
|
+
module CoverageStartSuppressor
|
|
14
|
+
def start(*_args) = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Suppresses expensive and irrelevant coverage startup/teardown during
|
|
18
|
+
# mutant child runs. Coverage artifacts are only required during the
|
|
19
|
+
# dedicated bootstrap phase.
|
|
20
|
+
module CoverageRuntimeSuppressors
|
|
21
|
+
def self.suppress_simplecov!
|
|
22
|
+
require "simplecov"
|
|
23
|
+
sc = Object.const_get(:SimpleCov) # steep:ignore Ruby::UnknownConstant
|
|
24
|
+
sc.external_at_exit = true if sc.respond_to?(:external_at_exit=)
|
|
25
|
+
return if sc.singleton_class.ancestors.include?(SimpleCovStartSuppressor)
|
|
26
|
+
|
|
27
|
+
sc.singleton_class.prepend(SimpleCovStartSuppressor)
|
|
28
|
+
rescue LoadError, NameError
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.suppress_coverage!
|
|
33
|
+
require "coverage"
|
|
34
|
+
cov = Object.const_get(:Coverage) # steep:ignore Ruby::UnknownConstant
|
|
35
|
+
return if cov.singleton_class.ancestors.include?(CoverageStartSuppressor)
|
|
36
|
+
|
|
37
|
+
cov.singleton_class.prepend(CoverageStartSuppressor)
|
|
38
|
+
rescue LoadError, NameError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
# Guarded to avoid a circular require: integration.rb requires this file at its
|
|
5
|
+
# tail, by which point Base is already defined, so the require is skipped.
|
|
6
|
+
require_relative "../integration" unless defined?(Henitai::Integration::Base)
|
|
7
|
+
|
|
8
|
+
module Henitai
|
|
9
|
+
module Integration
|
|
10
|
+
# Minitest integration adapter.
|
|
11
|
+
#
|
|
12
|
+
# A sibling of Rspec behind Base: it shares the framework-agnostic mutant-run
|
|
13
|
+
# orchestration (MutantRunSupport) and test selection (RspecTestSelection)
|
|
14
|
+
# but implements its own Minitest-specific test invocation and suite command.
|
|
15
|
+
# Per-test coverage collection is not yet wired into this path.
|
|
16
|
+
class Minitest < Base
|
|
17
|
+
include MutantRunSupport
|
|
18
|
+
include RspecTestSelection
|
|
19
|
+
|
|
20
|
+
DEFAULT_SUITE_TIMEOUT = 300.0
|
|
21
|
+
|
|
22
|
+
def test_files = spec_files
|
|
23
|
+
|
|
24
|
+
def per_test_coverage_supported?
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def run_mutant(mutant:, test_files:, timeout:)
|
|
29
|
+
setup_load_path
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def spawn_mutant(mutant:, test_files:)
|
|
34
|
+
setup_load_path
|
|
35
|
+
super
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def run_in_child(mutant:, test_files:, log_paths:)
|
|
39
|
+
ENV["RAILS_ENV"] = "test" unless ENV["RAILS_ENV"] == "test"
|
|
40
|
+
preload_environment
|
|
41
|
+
super
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def run_suite(test_files, timeout: DEFAULT_SUITE_TIMEOUT)
|
|
45
|
+
log_paths = scenario_log_paths("baseline")
|
|
46
|
+
pid = spawn_suite_process(test_files, log_paths)
|
|
47
|
+
wait_result = wait_with_timeout(pid, timeout)
|
|
48
|
+
build_result(wait_result, log_paths)
|
|
49
|
+
ensure
|
|
50
|
+
cleanup_suite_process(pid, wait_result)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def suite_command(test_files)
|
|
56
|
+
["bundle", "exec", "ruby", "-I", "test",
|
|
57
|
+
"-r", "henitai/minitest_simplecov",
|
|
58
|
+
"-r", "henitai/minitest_coverage_hook",
|
|
59
|
+
"-e", "ARGV.each { |f| require File.expand_path(f) }",
|
|
60
|
+
*test_files]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def run_tests(test_files)
|
|
64
|
+
suppress_simplecov!
|
|
65
|
+
suppress_minitest_autorun!
|
|
66
|
+
test_files.each { |file| require File.expand_path(file) }
|
|
67
|
+
# @type var empty_args: Array[String]
|
|
68
|
+
empty_args = []
|
|
69
|
+
status = ::Minitest.run(empty_args)
|
|
70
|
+
return status if status.is_a?(Integer)
|
|
71
|
+
|
|
72
|
+
status == true ? 0 : 1
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def preload_environment
|
|
76
|
+
env_file = File.expand_path("config/environment.rb")
|
|
77
|
+
require env_file if File.exist?(env_file)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def setup_load_path
|
|
81
|
+
test_dir = File.expand_path("test")
|
|
82
|
+
$LOAD_PATH.unshift(test_dir) unless $LOAD_PATH.include?(test_dir)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def suppress_minitest_autorun!
|
|
86
|
+
require "minitest"
|
|
87
|
+
singleton_class = ::Minitest.singleton_class
|
|
88
|
+
suppressor = @minitest_autorun_suppressor ||= Module.new.tap do |mod|
|
|
89
|
+
mod.define_method(:autorun) { nil }
|
|
90
|
+
end
|
|
91
|
+
return if singleton_class.ancestors.include?(suppressor)
|
|
92
|
+
|
|
93
|
+
singleton_class.prepend(suppressor)
|
|
94
|
+
nil
|
|
95
|
+
rescue LoadError, NameError
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def subprocess_env
|
|
100
|
+
env = super
|
|
101
|
+
env["RAILS_ENV"] = "test" unless ENV["RAILS_ENV"] == "test"
|
|
102
|
+
env["PARALLEL_WORKERS"] = "1"
|
|
103
|
+
env
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def spawn_suite_process(test_files, log_paths)
|
|
107
|
+
FileUtils.mkdir_p(File.dirname(log_paths[:stdout_path]))
|
|
108
|
+
File.open(log_paths[:stdout_path], "w") do |stdout_file|
|
|
109
|
+
File.open(log_paths[:stderr_path], "w") do |stderr_file|
|
|
110
|
+
Process.spawn(
|
|
111
|
+
subprocess_env,
|
|
112
|
+
*suite_command(test_files),
|
|
113
|
+
out: stdout_file,
|
|
114
|
+
err: stderr_file
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def cleanup_suite_process(pid, wait_result)
|
|
121
|
+
return unless pid
|
|
122
|
+
|
|
123
|
+
cleanup_child_process(pid)
|
|
124
|
+
reap_child(pid) if wait_result.nil?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def spec_files
|
|
128
|
+
(Dir.glob("test/**/*_test.rb") + Dir.glob("test/**/*_spec.rb"))
|
|
129
|
+
.reject { |f| f.start_with?("test/system/") }
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
module Integration
|
|
7
|
+
# Framework-agnostic orchestration for running a single mutant in a child
|
|
8
|
+
# process and turning the captured child output into a ScenarioExecutionResult.
|
|
9
|
+
#
|
|
10
|
+
# The framework-specific test invocation is delegated to #run_tests, which
|
|
11
|
+
# including classes must implement.
|
|
12
|
+
module MutantRunSupport
|
|
13
|
+
def spawn_mutant(mutant:, test_files:)
|
|
14
|
+
log_paths = scenario_log_paths(mutant_log_name(mutant))
|
|
15
|
+
RspecProcessRunner.new.spawn_mutant(self, mutant:, test_files:, log_paths:)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run_mutant(mutant:, test_files:, timeout:)
|
|
19
|
+
RspecProcessRunner.new.run_mutant(self, mutant:, test_files:, timeout:)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def scenario_log_paths(name)
|
|
23
|
+
reports_dir = ENV.fetch("HENITAI_REPORTS_DIR", "reports")
|
|
24
|
+
log_dir = File.join(reports_dir, "mutation-logs")
|
|
25
|
+
{
|
|
26
|
+
stdout_path: File.join(log_dir, "#{name}.stdout.log"),
|
|
27
|
+
stderr_path: File.join(log_dir, "#{name}.stderr.log"),
|
|
28
|
+
log_path: File.join(log_dir, "#{name}.log")
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def build_result(wait_result, log_paths)
|
|
33
|
+
stdout = scenario_log_support.read_log_file(log_paths[:stdout_path])
|
|
34
|
+
stderr = scenario_log_support.read_log_file(log_paths[:stderr_path])
|
|
35
|
+
scenario_log_support.write_combined_log(log_paths[:log_path], stdout, stderr)
|
|
36
|
+
|
|
37
|
+
ScenarioExecutionResult.build(
|
|
38
|
+
wait_result:,
|
|
39
|
+
stdout:,
|
|
40
|
+
stderr:,
|
|
41
|
+
log_path: log_paths[:log_path]
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def run_in_child(mutant:, test_files:, log_paths:)
|
|
46
|
+
Thread.report_on_exception = false
|
|
47
|
+
with_subprocess_env do
|
|
48
|
+
suppress_simplecov!
|
|
49
|
+
suppress_coverage!
|
|
50
|
+
install_debug_timeout_trap if debug_child?
|
|
51
|
+
with_non_interactive_stdin do
|
|
52
|
+
run_child_activation_and_tests(mutant:, test_files:, log_paths:)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def mutant_log_name(mutant)
|
|
58
|
+
"mutant-#{mutant.id}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def run_child_activation_and_tests(mutant:, test_files:, log_paths:)
|
|
64
|
+
scenario_log_support.with_coverage_dir(mutant.id) do
|
|
65
|
+
scenario_log_support.capture_child_output(log_paths) do
|
|
66
|
+
debug_child_mutant_meta(mutant) if debug_child?
|
|
67
|
+
debug_child_activation_start(mutant.id)
|
|
68
|
+
activation_result = Mutant::Activator.activate!(mutant)
|
|
69
|
+
debug_child_activation_check if debug_child?
|
|
70
|
+
debug_child_activation_end(activation_result, test_files:)
|
|
71
|
+
activation_result == :compile_error ? 2 : run_tests(test_files)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
module Integration
|
|
5
|
+
# Drives an in-process RSpec run inside the mutant child. Kept separate
|
|
6
|
+
# from the framework-agnostic debug helpers so the RSpec-specific runner
|
|
7
|
+
# wiring stays in one place.
|
|
8
|
+
module RspecChildRunner
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def run_rspec_runner(test_files)
|
|
12
|
+
debug_child_puts("[henitai-debug-child] build_rspec_runner_start")
|
|
13
|
+
runner = build_rspec_runner
|
|
14
|
+
debug_child_puts("[henitai-debug-child] build_rspec_runner_return")
|
|
15
|
+
debug_child_puts("[henitai-debug-child] configure_rspec_runner_start")
|
|
16
|
+
configure_rspec_runner(runner)
|
|
17
|
+
debug_child_puts("[henitai-debug-child] configure_rspec_runner_return")
|
|
18
|
+
load_rspec_spec_files(test_files)
|
|
19
|
+
run_rspec_specs(runner)
|
|
20
|
+
rescue SystemExit => e
|
|
21
|
+
debug_child_puts("[henitai-debug-child] runner_run_system_exit status=#{e.status.inspect}")
|
|
22
|
+
raise
|
|
23
|
+
ensure
|
|
24
|
+
debug_child_puts("[henitai-debug-child] runner_run_ensure")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def build_rspec_runner
|
|
28
|
+
# @type var empty_args: Array[String]
|
|
29
|
+
empty_args = []
|
|
30
|
+
configuration_options = ::RSpec::Core.const_get(:ConfigurationOptions).new(empty_args)
|
|
31
|
+
::RSpec::Core::Runner.__send__(:new, configuration_options)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def configure_rspec_runner(runner)
|
|
35
|
+
debug_child_puts("[henitai-debug-child] trap_interrupt_start")
|
|
36
|
+
::RSpec::Core::Runner.__send__(:trap_interrupt)
|
|
37
|
+
debug_child_puts("[henitai-debug-child] trap_interrupt_return")
|
|
38
|
+
debug_child_puts("[henitai-debug-child] runner_configure_start")
|
|
39
|
+
runner.send(:configure, $stderr, $stdout)
|
|
40
|
+
debug_child_puts("[henitai-debug-child] runner_configure_return")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def load_rspec_spec_files(test_files)
|
|
44
|
+
debug_child_puts("[henitai-debug-child] load_spec_files_start")
|
|
45
|
+
::RSpec.__send__(:configuration).files_to_run = test_files.map do |file|
|
|
46
|
+
File.expand_path(file)
|
|
47
|
+
end
|
|
48
|
+
::RSpec.__send__(:configuration).load_spec_files
|
|
49
|
+
debug_child_example_count("after_load")
|
|
50
|
+
debug_child_puts("[henitai-debug-child] load_spec_files_return")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def run_rspec_specs(runner)
|
|
54
|
+
debug_child_puts("[henitai-debug-child] run_specs_start")
|
|
55
|
+
result = runner.send(:run_specs, ::RSpec.__send__(:world).ordered_example_groups)
|
|
56
|
+
debug_child_puts("[henitai-debug-child] run_specs_return result=#{result.inspect}")
|
|
57
|
+
result
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|