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.
@@ -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
- log_paths = integration.scenario_log_paths(mutant_log_name(mutant))
9
- pid = fork_mutant_process(integration, mutant, test_files, log_paths)
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
- finalize_mutant_run(integration, pid, wait_result)
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
- private
31
-
32
- def fork_mutant_process(integration, mutant, test_files, log_paths)
33
- Process.fork do
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
@@ -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
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
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
- return handle_timeout(pid)
167
- end
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
- pause(0.01)
170
- end
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
- pause(2.0)
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
- pause(2.0)
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
- end
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
- spec_files
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
- "-S", "rspec", *test_files,
292
- "--format", "progress",
293
- "--format", "Henitai::CoverageFormatter"
560
+ "-e", rspec_suite_runner_script,
561
+ *test_files
294
562
  ]
295
563
  end
296
564
 
297
- def run_tests(test_files)
298
- require "rspec/core"
299
- status = RSpec::Core::Runner.run(test_files + rspec_options)
300
- return status if status.is_a?(Integer)
565
+ def rspec_suite_runner_script
566
+ <<~RUBY
567
+ require "rspec/core"
301
568
 
302
- status == true ? 0 : 1
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
- paths = Dir.glob("spec/**/*_spec.rb")
330
- paths - excluded_spec_files
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
- scenario_log_support.with_coverage_dir(mutant.id) do
445
- scenario_log_support.capture_child_output(log_paths) do
446
- return 2 if Mutant::Activator.activate!(mutant) == :compile_error
447
-
448
- run_tests(test_files)
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"