henitai 0.1.8 → 0.2.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/CHANGELOG.md +86 -1
- data/README.md +18 -4
- data/lib/henitai/cli.rb +81 -3
- data/lib/henitai/configuration.rb +24 -11
- data/lib/henitai/coverage_bootstrapper.rb +24 -24
- data/lib/henitai/execution_engine.rb +3 -9
- data/lib/henitai/git_diff_analyzer.rb +34 -0
- data/lib/henitai/integration/rspec_process_runner.rb +66 -13
- data/lib/henitai/integration.rb +403 -38
- data/lib/henitai/mutant/activator.rb +14 -2
- data/lib/henitai/mutant.rb +13 -2
- data/lib/henitai/mutant_generator.rb +21 -2
- data/lib/henitai/mutant_history_store.rb +7 -22
- data/lib/henitai/mutant_identity.rb +34 -0
- data/lib/henitai/parallel_execution_runner.rb +29 -11
- data/lib/henitai/process_wakeup.rb +49 -0
- data/lib/henitai/process_worker_runner.rb +434 -0
- data/lib/henitai/reporter.rb +76 -3
- data/lib/henitai/result.rb +39 -8
- data/lib/henitai/runner.rb +203 -14
- data/lib/henitai/scenario_execution_result.rb +16 -3
- 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_selector.rb +36 -0
- data/lib/henitai/survivor_test_filter.rb +72 -0
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +8 -0
- data/sig/henitai.rbs +205 -9
- metadata +23 -2
|
@@ -2,15 +2,68 @@
|
|
|
2
2
|
|
|
3
3
|
module Henitai
|
|
4
4
|
module Integration
|
|
5
|
+
# Tracks real OS child pids for scheduler observability.
|
|
6
|
+
# Gated on HENITAI_DEBUG_SCHEDULER=1. Thread-safe.
|
|
7
|
+
module SchedulerDiagnostics
|
|
8
|
+
@mutex = Mutex.new
|
|
9
|
+
@intervals = []
|
|
10
|
+
@live_count = 0
|
|
11
|
+
@max_concurrent = 0
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def enabled?
|
|
15
|
+
ENV["HENITAI_DEBUG_SCHEDULER"] == "1"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def child_started(pid)
|
|
19
|
+
return unless enabled?
|
|
20
|
+
|
|
21
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
@live_count += 1
|
|
24
|
+
@max_concurrent = [@max_concurrent, @live_count].max
|
|
25
|
+
@intervals << { pid: pid, started_at: started_at, ended_at: nil }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def child_ended(pid)
|
|
30
|
+
return if pid.nil? || !enabled?
|
|
31
|
+
|
|
32
|
+
ended_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
@live_count -= 1
|
|
35
|
+
entry = @intervals.rfind { |i| i[:pid] == pid && i[:ended_at].nil? }
|
|
36
|
+
entry[:ended_at] = ended_at if entry
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def summary
|
|
41
|
+
@mutex.synchronize { { max_concurrent: @max_concurrent, intervals: @intervals.dup } }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def reset!
|
|
45
|
+
@mutex.synchronize do
|
|
46
|
+
@intervals = []
|
|
47
|
+
@live_count = 0
|
|
48
|
+
@max_concurrent = 0
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Captures the PID and log paths for a spawned mutant child process.
|
|
55
|
+
ChildHandle = Struct.new(:pid, :log_paths)
|
|
56
|
+
|
|
5
57
|
# Runs RSpec child and suite processes on behalf of the integration.
|
|
6
58
|
class RspecProcessRunner
|
|
7
59
|
def run_mutant(integration, mutant:, test_files:, timeout:)
|
|
8
|
-
|
|
9
|
-
pid
|
|
10
|
-
wait_result = integration.wait_with_timeout(pid, timeout)
|
|
11
|
-
integration.build_result(wait_result, log_paths)
|
|
60
|
+
handle = integration.spawn_mutant(mutant:, test_files:)
|
|
61
|
+
SchedulerDiagnostics.child_started(handle.pid)
|
|
62
|
+
wait_result = integration.wait_with_timeout(handle.pid, timeout)
|
|
63
|
+
integration.build_result(wait_result, handle.log_paths)
|
|
12
64
|
ensure
|
|
13
|
-
|
|
65
|
+
SchedulerDiagnostics.child_ended(handle&.pid)
|
|
66
|
+
finalize_mutant_run(integration, handle&.pid, wait_result)
|
|
14
67
|
end
|
|
15
68
|
|
|
16
69
|
def run_suite(integration, test_files, timeout:)
|
|
@@ -27,10 +80,11 @@ module Henitai
|
|
|
27
80
|
end
|
|
28
81
|
end
|
|
29
82
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
83
|
+
# Called from Integration::Rspec#spawn_mutant (and Minitest#spawn_mutant).
|
|
84
|
+
# Forks a child, sets process group, activates the mutant, runs tests.
|
|
85
|
+
# Returns a ChildHandle with the forked pid and log_paths.
|
|
86
|
+
def spawn_mutant(integration, mutant:, test_files:, log_paths:)
|
|
87
|
+
pid = Process.fork do
|
|
34
88
|
Process.setpgid(0, 0)
|
|
35
89
|
ENV["HENITAI_MUTANT_ID"] = mutant.id
|
|
36
90
|
Process.exit(
|
|
@@ -41,18 +95,17 @@ module Henitai
|
|
|
41
95
|
)
|
|
42
96
|
)
|
|
43
97
|
end
|
|
98
|
+
ChildHandle.new(pid:, log_paths:)
|
|
44
99
|
end
|
|
45
100
|
|
|
101
|
+
private
|
|
102
|
+
|
|
46
103
|
def finalize_mutant_run(integration, pid, wait_result)
|
|
47
104
|
return unless pid
|
|
48
105
|
|
|
49
106
|
integration.cleanup_process_group(pid) unless wait_result == :timeout
|
|
50
107
|
integration.reap_child(pid) if wait_result.nil?
|
|
51
108
|
end
|
|
52
|
-
|
|
53
|
-
def mutant_log_name(mutant)
|
|
54
|
-
"mutant-#{mutant.id}"
|
|
55
|
-
end
|
|
56
109
|
end
|
|
57
110
|
end
|
|
58
111
|
end
|
data/lib/henitai/integration.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "stringio"
|
|
5
|
+
require_relative "process_wakeup"
|
|
4
6
|
require_relative "integration/rspec_process_runner"
|
|
5
7
|
|
|
6
8
|
module Henitai
|
|
@@ -108,6 +110,204 @@ module Henitai
|
|
|
108
110
|
end
|
|
109
111
|
end
|
|
110
112
|
|
|
113
|
+
# Shared debug helpers for child-run diagnostics.
|
|
114
|
+
# Debug helpers are intentionally grouped here so the child-run diagnostics
|
|
115
|
+
# stay isolated from the main integration flow.
|
|
116
|
+
# rubocop:disable Metrics/ModuleLength
|
|
117
|
+
module ChildDebugSupport
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def run_rspec_runner(test_files)
|
|
121
|
+
debug_child_puts("[henitai-debug-child] build_rspec_runner_start")
|
|
122
|
+
runner = build_rspec_runner
|
|
123
|
+
debug_child_puts("[henitai-debug-child] build_rspec_runner_return")
|
|
124
|
+
debug_child_puts("[henitai-debug-child] configure_rspec_runner_start")
|
|
125
|
+
configure_rspec_runner(runner)
|
|
126
|
+
debug_child_puts("[henitai-debug-child] configure_rspec_runner_return")
|
|
127
|
+
load_rspec_spec_files(test_files)
|
|
128
|
+
run_rspec_specs(runner)
|
|
129
|
+
rescue SystemExit => e
|
|
130
|
+
debug_child_puts("[henitai-debug-child] runner_run_system_exit status=#{e.status.inspect}")
|
|
131
|
+
raise
|
|
132
|
+
ensure
|
|
133
|
+
debug_child_puts("[henitai-debug-child] runner_run_ensure")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def build_rspec_runner
|
|
137
|
+
# @type var empty_args: Array[String]
|
|
138
|
+
empty_args = []
|
|
139
|
+
configuration_options = ::RSpec::Core.const_get(:ConfigurationOptions).new(empty_args)
|
|
140
|
+
::RSpec::Core::Runner.__send__(:new, configuration_options)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def configure_rspec_runner(runner)
|
|
144
|
+
debug_child_puts("[henitai-debug-child] trap_interrupt_start")
|
|
145
|
+
::RSpec::Core::Runner.__send__(:trap_interrupt)
|
|
146
|
+
debug_child_puts("[henitai-debug-child] trap_interrupt_return")
|
|
147
|
+
debug_child_puts("[henitai-debug-child] runner_configure_start")
|
|
148
|
+
runner.send(:configure, $stderr, $stdout)
|
|
149
|
+
debug_child_puts("[henitai-debug-child] runner_configure_return")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def load_rspec_spec_files(test_files)
|
|
153
|
+
debug_child_puts("[henitai-debug-child] load_spec_files_start")
|
|
154
|
+
::RSpec.__send__(:configuration).files_to_run = test_files.map do |file|
|
|
155
|
+
File.expand_path(file)
|
|
156
|
+
end
|
|
157
|
+
::RSpec.__send__(:configuration).load_spec_files
|
|
158
|
+
debug_child_example_count("after_load")
|
|
159
|
+
debug_child_puts("[henitai-debug-child] load_spec_files_return")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def run_rspec_specs(runner)
|
|
163
|
+
debug_child_puts("[henitai-debug-child] run_specs_start")
|
|
164
|
+
result = runner.send(:run_specs, ::RSpec.__send__(:world).ordered_example_groups)
|
|
165
|
+
debug_child_puts("[henitai-debug-child] run_specs_return result=#{result.inspect}")
|
|
166
|
+
result
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def debug_child? = ENV["HENITAI_DEBUG_CHILD"] == "1"
|
|
170
|
+
|
|
171
|
+
def debug_child_puts(message)
|
|
172
|
+
$stdout.puts(message)
|
|
173
|
+
$stdout.flush
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def debug_child_rspec_trace(test_files:, rspec_options:, rspec_argv:)
|
|
177
|
+
return unless debug_child?
|
|
178
|
+
|
|
179
|
+
files_exist = test_files.map { |f| [f, File.exist?(f)] }.inspect
|
|
180
|
+
loaded_features = loaded_feature_map(test_files).inspect # steep:ignore Ruby::NoMethod
|
|
181
|
+
|
|
182
|
+
debug_child_puts(
|
|
183
|
+
"[henitai-debug-child] cwd=#{Dir.pwd}\n" \
|
|
184
|
+
"[henitai-debug-child] files_exist=#{files_exist}\n" \
|
|
185
|
+
"[henitai-debug-child] loaded_features_check=#{loaded_features}\n" \
|
|
186
|
+
"[henitai-debug-child] test_files=#{test_files.inspect}\n" \
|
|
187
|
+
"[henitai-debug-child] rspec_options=#{rspec_options.inspect}\n" \
|
|
188
|
+
"[henitai-debug-child] rspec_argv=#{rspec_argv.inspect}"
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def debug_child_rspec_exit(status)
|
|
193
|
+
return unless debug_child?
|
|
194
|
+
|
|
195
|
+
debug_child_puts("[henitai-debug-child] RSpec result=#{status.inspect}")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def suppress_simplecov!
|
|
199
|
+
CoverageRuntimeSuppressors.suppress_simplecov!
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def suppress_coverage!
|
|
203
|
+
CoverageRuntimeSuppressors.suppress_coverage!
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def debug_child_example_count(stage) # steep:ignore Ruby::UndeclaredMethodDefinition
|
|
207
|
+
return unless debug_child?
|
|
208
|
+
|
|
209
|
+
count = rspec_world_example_count
|
|
210
|
+
debug_child_puts(
|
|
211
|
+
"[henitai-debug-child] rspec_world_example_count_#{stage}=#{count.inspect}"
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def debug_child_activation_start(mutant_id)
|
|
216
|
+
return unless debug_child?
|
|
217
|
+
|
|
218
|
+
debug_child_puts("[henitai-debug-child] activate_start mutant=#{mutant_id}")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def debug_child_activation_end(activation_result, test_files:)
|
|
222
|
+
return unless debug_child?
|
|
223
|
+
|
|
224
|
+
debug_child_puts(
|
|
225
|
+
"[henitai-debug-child] activate_end result=#{activation_result.inspect}\n" \
|
|
226
|
+
"[henitai-debug-child] run_tests_start test_files=#{test_files.inspect}"
|
|
227
|
+
)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def debug_child_mutant_meta(mutant)
|
|
231
|
+
stable_id = mutant.respond_to?(:stable_id) ? mutant.stable_id : nil
|
|
232
|
+
operator = mutant.respond_to?(:operator) ? mutant.operator : nil
|
|
233
|
+
has_subject_expression =
|
|
234
|
+
mutant.respond_to?(:subject) && mutant.subject.respond_to?(:expression)
|
|
235
|
+
subject_expression = has_subject_expression ? mutant.subject.expression : nil
|
|
236
|
+
location = mutant.respond_to?(:location) ? mutant.location.inspect : nil
|
|
237
|
+
|
|
238
|
+
debug_child_puts(
|
|
239
|
+
"[henitai-debug-child] mutant_meta stableId=#{stable_id}\n" \
|
|
240
|
+
"[henitai-debug-child] mutant_meta operator=#{operator}\n" \
|
|
241
|
+
"[henitai-debug-child] mutant_meta subject=#{subject_expression}\n" \
|
|
242
|
+
"[henitai-debug-child] mutant_meta location=#{location}\n"
|
|
243
|
+
)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def debug_child_activation_check
|
|
247
|
+
location = begin
|
|
248
|
+
Henitai::Runner.instance_method(:resolve_subjects).source_location&.join(":")
|
|
249
|
+
rescue StandardError
|
|
250
|
+
nil
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
debug_child_puts(
|
|
254
|
+
"[henitai-debug-child] activation_check resolve_subjects_location=#{location}\n"
|
|
255
|
+
)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def loaded_feature_map(test_files) = test_files.map { |file| [file, loaded_feature?(file)] } # steep:ignore Ruby::UndeclaredMethodDefinition
|
|
259
|
+
|
|
260
|
+
def loaded_feature?(file) # steep:ignore Ruby::UndeclaredMethodDefinition
|
|
261
|
+
expanded = File.expand_path(file)
|
|
262
|
+
candidates = [expanded, "#{expanded}.rb", file, "#{file}.rb"].uniq
|
|
263
|
+
$LOADED_FEATURES.any? do |feature|
|
|
264
|
+
normalized = begin
|
|
265
|
+
File.expand_path(feature)
|
|
266
|
+
rescue StandardError
|
|
267
|
+
feature
|
|
268
|
+
end
|
|
269
|
+
candidates.include?(feature) || candidates.include?(normalized)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def rspec_world_example_count # steep:ignore Ruby::UndeclaredMethodDefinition
|
|
274
|
+
world = ::RSpec.__send__(:world)
|
|
275
|
+
world.example_count
|
|
276
|
+
rescue StandardError
|
|
277
|
+
nil
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def debug_child_timeout_dump(pid)
|
|
281
|
+
return unless debug_child?
|
|
282
|
+
|
|
283
|
+
debug_child_puts("[henitai-debug-child] timeout_signal_sent pid=#{pid}")
|
|
284
|
+
Process.kill(:USR1, pid)
|
|
285
|
+
pause(0.2)
|
|
286
|
+
rescue Errno::ESRCH
|
|
287
|
+
nil
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def install_debug_timeout_trap
|
|
291
|
+
Signal.trap("USR1") { debug_child_thread_dump("timeout") }
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def debug_child_thread_dump(reason)
|
|
295
|
+
return unless debug_child?
|
|
296
|
+
|
|
297
|
+
debug_child_puts("[henitai-debug-child] thread_dump reason=#{reason}")
|
|
298
|
+
Thread.list.each_with_index do |thread, index|
|
|
299
|
+
debug_child_puts(
|
|
300
|
+
"[henitai-debug-child] thread index=#{index} id=#{thread.object_id} " \
|
|
301
|
+
"status=#{thread.status.inspect}"
|
|
302
|
+
)
|
|
303
|
+
Array(thread.backtrace).each do |line|
|
|
304
|
+
debug_child_puts("[henitai-debug-child] #{line}")
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
# rubocop:enable Metrics/ModuleLength
|
|
310
|
+
|
|
111
311
|
# Integration adapter for RSpec.
|
|
112
312
|
#
|
|
113
313
|
# This class exists as the stable public entry point for the RSpec
|
|
@@ -127,6 +327,8 @@ module Henitai
|
|
|
127
327
|
|
|
128
328
|
# Base class for all integrations.
|
|
129
329
|
class Base
|
|
330
|
+
include ChildDebugSupport
|
|
331
|
+
|
|
130
332
|
# @param subject [Subject]
|
|
131
333
|
# @return [Array<String>] paths to test files that cover this subject
|
|
132
334
|
def select_tests(subject)
|
|
@@ -148,26 +350,33 @@ module Henitai
|
|
|
148
350
|
raise NotImplementedError
|
|
149
351
|
end
|
|
150
352
|
|
|
353
|
+
# Fork a child process for the mutant without waiting for it to finish.
|
|
354
|
+
# Returns a ChildHandle carrying the OS pid and log file paths.
|
|
355
|
+
# The caller is responsible for waiting and cleanup.
|
|
356
|
+
#
|
|
357
|
+
# @param mutant [Mutant]
|
|
358
|
+
# @param test_files [Array<String>]
|
|
359
|
+
# @return [RspecProcessRunner::ChildHandle]
|
|
360
|
+
def spawn_mutant(mutant:, test_files:)
|
|
361
|
+
raise NotImplementedError
|
|
362
|
+
end
|
|
363
|
+
|
|
151
364
|
def per_test_coverage_supported?
|
|
152
365
|
false
|
|
153
366
|
end
|
|
154
367
|
|
|
155
368
|
def wait_with_timeout(pid, timeout)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
loop do
|
|
159
|
-
wait_result = Process.wait(pid, Process::WNOHANG)
|
|
160
|
-
return Process.last_status if wait_result
|
|
161
|
-
|
|
162
|
-
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
163
|
-
final_wait_result = Process.wait(pid, Process::WNOHANG)
|
|
164
|
-
return Process.last_status if final_wait_result
|
|
369
|
+
wakeup = Henitai.const_get(:ProcessWakeup).new.install
|
|
370
|
+
return Process.last_status if wait_nonblocking(pid)
|
|
165
371
|
|
|
166
|
-
|
|
167
|
-
|
|
372
|
+
wakeup.wait(timeout)
|
|
373
|
+
wakeup.drain
|
|
374
|
+
return Process.last_status if wait_nonblocking(pid)
|
|
375
|
+
return Process.last_status if wait_nonblocking(pid)
|
|
168
376
|
|
|
169
|
-
|
|
170
|
-
|
|
377
|
+
handle_timeout(pid)
|
|
378
|
+
ensure
|
|
379
|
+
wakeup&.close
|
|
171
380
|
end
|
|
172
381
|
|
|
173
382
|
def reap_child(pid)
|
|
@@ -177,13 +386,22 @@ module Henitai
|
|
|
177
386
|
end
|
|
178
387
|
|
|
179
388
|
def cleanup_process_group(pid)
|
|
389
|
+
grace_period = 2.0
|
|
390
|
+
wakeup = Henitai.const_get(:ProcessWakeup).new.install
|
|
180
391
|
Process.kill(:SIGTERM, -pid)
|
|
181
|
-
|
|
392
|
+
return if wait_nonblocking(pid)
|
|
393
|
+
|
|
394
|
+
wakeup.wait(grace_period)
|
|
395
|
+
wakeup.drain
|
|
396
|
+
return if wait_nonblocking(pid)
|
|
397
|
+
|
|
182
398
|
Process.kill(:SIGKILL, -pid)
|
|
183
399
|
rescue Errno::EPERM
|
|
184
400
|
cleanup_child_process(pid)
|
|
185
401
|
rescue Errno::ESRCH
|
|
186
402
|
nil
|
|
403
|
+
ensure
|
|
404
|
+
wakeup&.close
|
|
187
405
|
end
|
|
188
406
|
|
|
189
407
|
private
|
|
@@ -194,6 +412,7 @@ module Henitai
|
|
|
194
412
|
|
|
195
413
|
def handle_timeout(pid)
|
|
196
414
|
begin
|
|
415
|
+
debug_child_timeout_dump(pid)
|
|
197
416
|
cleanup_process_group(pid)
|
|
198
417
|
ensure
|
|
199
418
|
reap_child(pid)
|
|
@@ -202,21 +421,32 @@ module Henitai
|
|
|
202
421
|
end
|
|
203
422
|
|
|
204
423
|
def cleanup_child_process(pid)
|
|
424
|
+
grace_period = 2.0
|
|
425
|
+
wakeup = Henitai.const_get(:ProcessWakeup).new.install
|
|
205
426
|
Process.kill(:SIGTERM, pid)
|
|
206
|
-
|
|
427
|
+
return if wait_nonblocking(pid)
|
|
428
|
+
|
|
429
|
+
wakeup.wait(grace_period)
|
|
430
|
+
wakeup.drain
|
|
431
|
+
return if wait_nonblocking(pid)
|
|
432
|
+
|
|
207
433
|
Process.kill(:SIGKILL, pid)
|
|
208
434
|
rescue Errno::EPERM, Errno::ESRCH
|
|
209
435
|
nil
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
def rspec_options
|
|
213
|
-
[]
|
|
436
|
+
ensure
|
|
437
|
+
wakeup&.close
|
|
214
438
|
end
|
|
215
439
|
|
|
216
440
|
def subprocess_env
|
|
217
441
|
{ "PARALLEL_WORKERS" => "1" }
|
|
218
442
|
end
|
|
219
443
|
|
|
444
|
+
def wait_nonblocking(pid)
|
|
445
|
+
Process.wait(pid, Process::WNOHANG)
|
|
446
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
447
|
+
nil
|
|
448
|
+
end
|
|
449
|
+
|
|
220
450
|
def scenario_log_support
|
|
221
451
|
@scenario_log_support ||= ScenarioLogSupport.new
|
|
222
452
|
end
|
|
@@ -241,6 +471,42 @@ module Henitai
|
|
|
241
471
|
end
|
|
242
472
|
end
|
|
243
473
|
end
|
|
474
|
+
|
|
475
|
+
def with_non_interactive_stdin
|
|
476
|
+
original_stdin = $stdin
|
|
477
|
+
$stdin = StringIO.new
|
|
478
|
+
yield
|
|
479
|
+
ensure
|
|
480
|
+
$stdin = original_stdin
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def run_tests(test_files)
|
|
484
|
+
require "rspec/core"
|
|
485
|
+
::RSpec.__send__(:configuration).fail_if_no_examples = true
|
|
486
|
+
debug_child_rspec_trace(test_files:, rspec_options: [], rspec_argv: test_files)
|
|
487
|
+
debug_child_example_count("before_run") # steep:ignore Ruby::NoMethod
|
|
488
|
+
debug_child_puts("[henitai-debug-child] runner_run_start")
|
|
489
|
+
status = run_rspec_runner(test_files)
|
|
490
|
+
debug_child_puts("[henitai-debug-child] runner_run_return status=#{status.inspect}")
|
|
491
|
+
debug_child_example_count("after_run") # steep:ignore Ruby::NoMethod
|
|
492
|
+
debug_child_rspec_exit(status)
|
|
493
|
+
return status if status.is_a?(Integer)
|
|
494
|
+
|
|
495
|
+
status == true ? 0 : 1
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def run_child_activation_and_tests(mutant:, test_files:, log_paths:)
|
|
499
|
+
scenario_log_support.with_coverage_dir(mutant.id) do
|
|
500
|
+
scenario_log_support.capture_child_output(log_paths) do
|
|
501
|
+
debug_child_mutant_meta(mutant) if debug_child?
|
|
502
|
+
debug_child_activation_start(mutant.id)
|
|
503
|
+
activation_result = Mutant::Activator.activate!(mutant)
|
|
504
|
+
debug_child_activation_check if debug_child?
|
|
505
|
+
debug_child_activation_end(activation_result, test_files:)
|
|
506
|
+
activation_result == :compile_error ? 2 : run_tests(test_files)
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
end
|
|
244
510
|
end
|
|
245
511
|
|
|
246
512
|
# RSpec integration adapter.
|
|
@@ -268,8 +534,11 @@ module Henitai
|
|
|
268
534
|
fallback_spec_files(subject)
|
|
269
535
|
end
|
|
270
536
|
|
|
271
|
-
def test_files
|
|
272
|
-
|
|
537
|
+
def test_files = spec_files
|
|
538
|
+
|
|
539
|
+
def spawn_mutant(mutant:, test_files:)
|
|
540
|
+
log_paths = scenario_log_paths(mutant_log_name(mutant))
|
|
541
|
+
RspecProcessRunner.new.spawn_mutant(self, mutant:, test_files:, log_paths:)
|
|
273
542
|
end
|
|
274
543
|
|
|
275
544
|
def run_mutant(mutant:, test_files:, timeout:)
|
|
@@ -288,18 +557,30 @@ module Henitai
|
|
|
288
557
|
[
|
|
289
558
|
"bundle", "exec", "ruby",
|
|
290
559
|
"-r", "henitai/rspec_coverage_formatter",
|
|
291
|
-
"-
|
|
292
|
-
|
|
293
|
-
"--format", "Henitai::CoverageFormatter"
|
|
560
|
+
"-e", rspec_suite_runner_script,
|
|
561
|
+
*test_files
|
|
294
562
|
]
|
|
295
563
|
end
|
|
296
564
|
|
|
297
|
-
def
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
return status if status.is_a?(Integer)
|
|
565
|
+
def rspec_suite_runner_script
|
|
566
|
+
<<~RUBY
|
|
567
|
+
require "rspec/core"
|
|
301
568
|
|
|
302
|
-
|
|
569
|
+
test_files = ARGV.map { |file| File.expand_path(file) }
|
|
570
|
+
config = RSpec.configuration
|
|
571
|
+
options = RSpec::Core::ConfigurationOptions.new(
|
|
572
|
+
["--format", "progress", "--format", "Henitai::CoverageFormatter"]
|
|
573
|
+
)
|
|
574
|
+
runner = RSpec::Core::Runner.send(:new, options)
|
|
575
|
+
|
|
576
|
+
RSpec::Core::Runner.send(:trap_interrupt)
|
|
577
|
+
runner.send(:configure, $stderr, $stdout)
|
|
578
|
+
config.files_to_run = test_files
|
|
579
|
+
config.load_spec_files
|
|
580
|
+
|
|
581
|
+
status = runner.send(:run_specs, RSpec.world.ordered_example_groups)
|
|
582
|
+
exit(status.is_a?(Integer) ? status : (status == true ? 0 : 1))
|
|
583
|
+
RUBY
|
|
303
584
|
end
|
|
304
585
|
|
|
305
586
|
def scenario_log_paths(name)
|
|
@@ -326,8 +607,10 @@ module Henitai
|
|
|
326
607
|
end
|
|
327
608
|
|
|
328
609
|
def spec_files
|
|
329
|
-
|
|
330
|
-
|
|
610
|
+
@spec_files ||= begin
|
|
611
|
+
paths = Dir.glob("spec/**/*_spec.rb")
|
|
612
|
+
paths - excluded_spec_files
|
|
613
|
+
end
|
|
331
614
|
end
|
|
332
615
|
|
|
333
616
|
def fallback_spec_files(subject)
|
|
@@ -343,7 +626,7 @@ module Henitai
|
|
|
343
626
|
end
|
|
344
627
|
|
|
345
628
|
def excluded_spec_files
|
|
346
|
-
rspec_exclude_patterns.flat_map { |pattern| Dir.glob(pattern) }.uniq
|
|
629
|
+
@excluded_spec_files ||= rspec_exclude_patterns.flat_map { |pattern| Dir.glob(pattern) }.uniq
|
|
347
630
|
end
|
|
348
631
|
|
|
349
632
|
def rspec_exclude_patterns
|
|
@@ -441,16 +724,19 @@ module Henitai
|
|
|
441
724
|
def run_in_child(mutant:, test_files:, log_paths:)
|
|
442
725
|
Thread.report_on_exception = false
|
|
443
726
|
with_subprocess_env do
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
end
|
|
727
|
+
suppress_simplecov!
|
|
728
|
+
suppress_coverage!
|
|
729
|
+
install_debug_timeout_trap if debug_child?
|
|
730
|
+
with_non_interactive_stdin do
|
|
731
|
+
run_child_activation_and_tests(mutant:, test_files:, log_paths:)
|
|
450
732
|
end
|
|
451
733
|
end
|
|
452
734
|
end
|
|
453
735
|
|
|
736
|
+
def mutant_log_name(mutant)
|
|
737
|
+
"mutant-#{mutant.id}"
|
|
738
|
+
end
|
|
739
|
+
|
|
454
740
|
def read_log_file(path)
|
|
455
741
|
return "" unless File.exist?(path)
|
|
456
742
|
|
|
@@ -491,6 +777,44 @@ module Henitai
|
|
|
491
777
|
end
|
|
492
778
|
end
|
|
493
779
|
|
|
780
|
+
# Prepended onto SimpleCov's singleton class to turn start into a no-op
|
|
781
|
+
# during mutant child runs. Using prepend avoids "method redefined" warnings.
|
|
782
|
+
module SimpleCovStartSuppressor
|
|
783
|
+
def start(*_args) = nil
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
# Suppresses expensive and irrelevant coverage startup/teardown during
|
|
787
|
+
# mutant child runs. Coverage artifacts are only required during the
|
|
788
|
+
# dedicated bootstrap phase.
|
|
789
|
+
module CoverageRuntimeSuppressors
|
|
790
|
+
def self.suppress_simplecov!
|
|
791
|
+
require "simplecov"
|
|
792
|
+
sc = Object.const_get(:SimpleCov) # steep:ignore Ruby::UnknownConstant
|
|
793
|
+
sc.external_at_exit = true if sc.respond_to?(:external_at_exit=)
|
|
794
|
+
return if sc.singleton_class.ancestors.include?(SimpleCovStartSuppressor)
|
|
795
|
+
|
|
796
|
+
sc.singleton_class.prepend(SimpleCovStartSuppressor)
|
|
797
|
+
rescue LoadError, NameError
|
|
798
|
+
nil
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
def self.suppress_coverage!
|
|
802
|
+
require "coverage"
|
|
803
|
+
cov = Object.const_get(:Coverage) # steep:ignore Ruby::UnknownConstant
|
|
804
|
+
return if cov.singleton_class.ancestors.include?(CoverageStartSuppressor)
|
|
805
|
+
|
|
806
|
+
cov.singleton_class.prepend(CoverageStartSuppressor)
|
|
807
|
+
rescue LoadError, NameError
|
|
808
|
+
nil
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
# Prepended onto the coverage gem's Coverage singleton to turn start
|
|
813
|
+
# into a no-op during mutant child runs.
|
|
814
|
+
module CoverageStartSuppressor
|
|
815
|
+
def start(*_args) = nil
|
|
816
|
+
end
|
|
817
|
+
|
|
494
818
|
# Minitest integration adapter.
|
|
495
819
|
#
|
|
496
820
|
# Coverage formatter injection remains implemented in the RSpec child
|
|
@@ -506,6 +830,11 @@ module Henitai
|
|
|
506
830
|
super
|
|
507
831
|
end
|
|
508
832
|
|
|
833
|
+
def spawn_mutant(mutant:, test_files:)
|
|
834
|
+
setup_load_path
|
|
835
|
+
super
|
|
836
|
+
end
|
|
837
|
+
|
|
509
838
|
def run_in_child(mutant:, test_files:, log_paths:)
|
|
510
839
|
ENV["RAILS_ENV"] = "test" unless ENV["RAILS_ENV"] == "test"
|
|
511
840
|
preload_environment
|
|
@@ -532,6 +861,8 @@ module Henitai
|
|
|
532
861
|
end
|
|
533
862
|
|
|
534
863
|
def run_tests(test_files)
|
|
864
|
+
suppress_simplecov!
|
|
865
|
+
suppress_minitest_autorun!
|
|
535
866
|
test_files.each { |file| require File.expand_path(file) }
|
|
536
867
|
# @type var empty_args: Array[String]
|
|
537
868
|
empty_args = []
|
|
@@ -551,6 +882,40 @@ module Henitai
|
|
|
551
882
|
$LOAD_PATH.unshift(test_dir) unless $LOAD_PATH.include?(test_dir)
|
|
552
883
|
end
|
|
553
884
|
|
|
885
|
+
def suppress_minitest_autorun!
|
|
886
|
+
require "minitest"
|
|
887
|
+
singleton_class = ::Minitest.singleton_class
|
|
888
|
+
suppressor = @minitest_autorun_suppressor ||= Module.new.tap do |mod|
|
|
889
|
+
mod.define_method(:autorun) { nil }
|
|
890
|
+
end
|
|
891
|
+
return if singleton_class.ancestors.include?(suppressor)
|
|
892
|
+
|
|
893
|
+
singleton_class.prepend(suppressor)
|
|
894
|
+
nil
|
|
895
|
+
rescue LoadError, NameError
|
|
896
|
+
nil
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
def suppress_simplecov!
|
|
900
|
+
require "simplecov"
|
|
901
|
+
sc = Object.const_get(:SimpleCov) # steep:ignore Ruby::UnknownConstant
|
|
902
|
+
return if sc.singleton_class.ancestors.include?(SimpleCovStartSuppressor)
|
|
903
|
+
|
|
904
|
+
sc.singleton_class.prepend(SimpleCovStartSuppressor)
|
|
905
|
+
rescue LoadError, NameError
|
|
906
|
+
nil
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
def suppress_coverage!
|
|
910
|
+
require "coverage"
|
|
911
|
+
cov = Object.const_get(:Coverage) # steep:ignore Ruby::UnknownConstant
|
|
912
|
+
return if cov.singleton_class.ancestors.include?(CoverageStartSuppressor)
|
|
913
|
+
|
|
914
|
+
cov.singleton_class.prepend(CoverageStartSuppressor)
|
|
915
|
+
rescue LoadError, NameError
|
|
916
|
+
nil
|
|
917
|
+
end
|
|
918
|
+
|
|
554
919
|
def subprocess_env
|
|
555
920
|
env = super
|
|
556
921
|
env["RAILS_ENV"] = "test" unless ENV["RAILS_ENV"] == "test"
|