henitai 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +105 -1
  3. data/README.md +138 -5
  4. data/assets/schema/henitai.schema.json +41 -0
  5. data/lib/henitai/available_cpu_count.rb +16 -23
  6. data/lib/henitai/canonical_report_merger.rb +107 -0
  7. data/lib/henitai/canonical_report_writer.rb +22 -0
  8. data/lib/henitai/checkpoint_reporter.rb +79 -0
  9. data/lib/henitai/cli/clean_command.rb +54 -0
  10. data/lib/henitai/cli/command_support.rb +52 -0
  11. data/lib/henitai/cli/init_command.rb +64 -0
  12. data/lib/henitai/cli/operator_command.rb +96 -0
  13. data/lib/henitai/cli/options.rb +84 -0
  14. data/lib/henitai/cli/run_command.rb +136 -0
  15. data/lib/henitai/cli/run_options.rb +108 -0
  16. data/lib/henitai/cli.rb +27 -407
  17. data/lib/henitai/composite_progress_reporter.rb +42 -0
  18. data/lib/henitai/configuration.rb +42 -9
  19. data/lib/henitai/configuration_validator/rules.rb +161 -0
  20. data/lib/henitai/configuration_validator/scalars.rb +169 -0
  21. data/lib/henitai/configuration_validator.rb +20 -240
  22. data/lib/henitai/coverage_bootstrapper.rb +54 -5
  23. data/lib/henitai/coverage_formatter.rb +5 -1
  24. data/lib/henitai/coverage_report_reader.rb +28 -5
  25. data/lib/henitai/eager_load.rb +36 -5
  26. data/lib/henitai/execution_engine/env_scope.rb +67 -0
  27. data/lib/henitai/execution_engine.rb +115 -53
  28. data/lib/henitai/generated_artifacts.rb +58 -0
  29. data/lib/henitai/incremental_filter.rb +100 -0
  30. data/lib/henitai/integration/base.rb +171 -0
  31. data/lib/henitai/integration/child_debug_support.rb +119 -0
  32. data/lib/henitai/integration/child_runtime_control.rb +50 -0
  33. data/lib/henitai/integration/coverage_suppression.rb +52 -0
  34. data/lib/henitai/integration/minitest.rb +101 -0
  35. data/lib/henitai/integration/minitest_load_path.rb +14 -0
  36. data/lib/henitai/integration/minitest_suite_command.rb +17 -0
  37. data/lib/henitai/integration/minitest_test_runner.rb +39 -0
  38. data/lib/henitai/integration/mutant_run_support.rb +77 -0
  39. data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
  40. data/lib/henitai/integration/rspec_child_runner.rb +61 -0
  41. data/lib/henitai/integration/rspec_test_selection.rb +138 -0
  42. data/lib/henitai/integration/scenario_log_support.rb +160 -0
  43. data/lib/henitai/integration.rb +22 -846
  44. data/lib/henitai/minitest_coverage_reporter.rb +4 -1
  45. data/lib/henitai/mutant/activator.rb +15 -79
  46. data/lib/henitai/mutant/parameter_source.rb +98 -0
  47. data/lib/henitai/mutant.rb +14 -6
  48. data/lib/henitai/mutant_history_store/sql.rb +90 -0
  49. data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
  50. data/lib/henitai/mutant_history_store.rb +67 -81
  51. data/lib/henitai/mutant_identity.rb +33 -2
  52. data/lib/henitai/mutation_skip_directives.rb +227 -0
  53. data/lib/henitai/operator.rb +3 -1
  54. data/lib/henitai/operators/equality_identity_operator.rb +51 -0
  55. data/lib/henitai/operators/equality_operator.rb +8 -3
  56. data/lib/henitai/operators.rb +1 -0
  57. data/lib/henitai/per_test_coverage.rb +66 -0
  58. data/lib/henitai/per_test_coverage_collector.rb +23 -6
  59. data/lib/henitai/per_test_coverage_selector.rb +11 -33
  60. data/lib/henitai/process_worker_runner.rb +48 -334
  61. data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
  62. data/lib/henitai/reporter.rb +258 -125
  63. data/lib/henitai/reports_directory_lock.rb +76 -0
  64. data/lib/henitai/result.rb +88 -23
  65. data/lib/henitai/runner.rb +137 -218
  66. data/lib/henitai/scenario_execution_result.rb +12 -0
  67. data/lib/henitai/slot_scheduler/draining.rb +146 -0
  68. data/lib/henitai/slot_scheduler/process_control.rb +43 -0
  69. data/lib/henitai/slot_scheduler.rb +272 -0
  70. data/lib/henitai/static_filter.rb +16 -6
  71. data/lib/henitai/survivor_rerun_strategy.rb +195 -0
  72. data/lib/henitai/survivor_test_filter.rb +1 -1
  73. data/lib/henitai/test_prioritizer.rb +28 -2
  74. data/lib/henitai/timeout_calibrator.rb +44 -0
  75. data/lib/henitai/unparse_helper.rb +5 -2
  76. data/lib/henitai/verdict_fingerprint.rb +155 -0
  77. data/lib/henitai/version.rb +1 -1
  78. data/lib/henitai.rb +16 -1
  79. data/sig/configuration_validator.rbs +46 -22
  80. data/sig/henitai.rbs +386 -96
  81. metadata +45 -3
  82. data/lib/henitai/parallel_execution_runner.rb +0 -153
@@ -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,119 @@
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
+ # Gated here (not only at call sites) so no unguarded caller can leak
14
+ # debug lines into every child log by default.
15
+ def debug_child_puts(message)
16
+ return unless debug_child?
17
+
18
+ $stdout.puts(message)
19
+ $stdout.flush
20
+ end
21
+
22
+ def debug_child_rspec_trace(test_files:, rspec_options:, rspec_argv:)
23
+ return unless debug_child?
24
+
25
+ files_exist = test_files.map { |f| [f, File.exist?(f)] }.inspect
26
+ loaded_features = loaded_feature_map(test_files).inspect # steep:ignore Ruby::NoMethod
27
+
28
+ debug_child_puts(
29
+ "[henitai-debug-child] cwd=#{Dir.pwd}\n" \
30
+ "[henitai-debug-child] files_exist=#{files_exist}\n" \
31
+ "[henitai-debug-child] loaded_features_check=#{loaded_features}\n" \
32
+ "[henitai-debug-child] test_files=#{test_files.inspect}\n" \
33
+ "[henitai-debug-child] rspec_options=#{rspec_options.inspect}\n" \
34
+ "[henitai-debug-child] rspec_argv=#{rspec_argv.inspect}"
35
+ )
36
+ end
37
+
38
+ def debug_child_rspec_exit(status)
39
+ return unless debug_child?
40
+
41
+ debug_child_puts("[henitai-debug-child] RSpec result=#{status.inspect}")
42
+ end
43
+
44
+ def debug_child_example_count(stage) # steep:ignore Ruby::UndeclaredMethodDefinition
45
+ return unless debug_child?
46
+
47
+ count = rspec_world_example_count
48
+ debug_child_puts(
49
+ "[henitai-debug-child] rspec_world_example_count_#{stage}=#{count.inspect}"
50
+ )
51
+ end
52
+
53
+ def debug_child_activation_start(mutant_id)
54
+ return unless debug_child?
55
+
56
+ debug_child_puts("[henitai-debug-child] activate_start mutant=#{mutant_id}")
57
+ end
58
+
59
+ def debug_child_activation_end(activation_result, test_files:)
60
+ return unless debug_child?
61
+
62
+ debug_child_puts(
63
+ "[henitai-debug-child] activate_end result=#{activation_result.inspect}\n" \
64
+ "[henitai-debug-child] run_tests_start test_files=#{test_files.inspect}"
65
+ )
66
+ end
67
+
68
+ def debug_child_mutant_meta(mutant)
69
+ stable_id = mutant.respond_to?(:stable_id) ? mutant.stable_id : nil
70
+ operator = mutant.respond_to?(:operator) ? mutant.operator : nil
71
+ has_subject_expression =
72
+ mutant.respond_to?(:subject) && mutant.subject.respond_to?(:expression)
73
+ subject_expression = has_subject_expression ? mutant.subject.expression : nil
74
+ location = mutant.respond_to?(:location) ? mutant.location.inspect : nil
75
+
76
+ debug_child_puts(
77
+ "[henitai-debug-child] mutant_meta stableId=#{stable_id}\n" \
78
+ "[henitai-debug-child] mutant_meta operator=#{operator}\n" \
79
+ "[henitai-debug-child] mutant_meta subject=#{subject_expression}\n" \
80
+ "[henitai-debug-child] mutant_meta location=#{location}\n"
81
+ )
82
+ end
83
+
84
+ def debug_child_activation_check
85
+ location = begin
86
+ Henitai::Runner.instance_method(:resolve_subjects).source_location&.join(":") # henitai:disable
87
+ rescue StandardError
88
+ nil
89
+ end
90
+
91
+ debug_child_puts(
92
+ "[henitai-debug-child] activation_check resolve_subjects_location=#{location}\n"
93
+ )
94
+ end
95
+
96
+ def loaded_feature_map(test_files) = test_files.map { |file| [file, loaded_feature?(file)] } # steep:ignore Ruby::UndeclaredMethodDefinition
97
+
98
+ def loaded_feature?(file) # steep:ignore Ruby::UndeclaredMethodDefinition
99
+ expanded = File.expand_path(file)
100
+ candidates = [expanded, "#{expanded}.rb", file, "#{file}.rb"].uniq
101
+ $LOADED_FEATURES.any? do |feature|
102
+ normalized = begin
103
+ File.expand_path(feature)
104
+ rescue StandardError
105
+ feature
106
+ end
107
+ candidates.include?(feature) || candidates.include?(normalized)
108
+ end
109
+ end
110
+
111
+ def rspec_world_example_count # steep:ignore Ruby::UndeclaredMethodDefinition
112
+ world = ::RSpec.world
113
+ world.example_count
114
+ rescue StandardError
115
+ nil
116
+ end
117
+ end
118
+ end
119
+ 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,52 @@
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
+ # True once coverage startup has been suppressed in this process —
22
+ # consumers (e.g. PerTestCoverageCollector) can then treat missing
23
+ # coverage as expected instead of warning about it.
24
+ def self.active? = @active ? true : false
25
+
26
+ def self.activate! = @active = true
27
+
28
+ def self.suppress_simplecov!
29
+ activate!
30
+ require "simplecov"
31
+ sc = Object.const_get(:SimpleCov) # steep:ignore Ruby::UnknownConstant
32
+ sc.external_at_exit = true if sc.respond_to?(:external_at_exit=)
33
+ return if sc.singleton_class.ancestors.include?(SimpleCovStartSuppressor)
34
+
35
+ sc.singleton_class.prepend(SimpleCovStartSuppressor)
36
+ rescue LoadError, NameError
37
+ nil
38
+ end
39
+
40
+ def self.suppress_coverage!
41
+ activate!
42
+ require "coverage"
43
+ cov = Object.const_get(:Coverage) # steep:ignore Ruby::UnknownConstant
44
+ return if cov.singleton_class.ancestors.include?(CoverageStartSuppressor)
45
+
46
+ cov.singleton_class.prepend(CoverageStartSuppressor)
47
+ rescue LoadError, NameError
48
+ nil
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,101 @@
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
+ require_relative "minitest_load_path"
8
+ require_relative "minitest_suite_command"
9
+ require_relative "minitest_test_runner"
10
+ require_relative "rails_environment_preloader"
11
+
12
+ module Henitai
13
+ module Integration
14
+ # Minitest integration adapter.
15
+ #
16
+ # A sibling of Rspec behind Base: it shares the framework-agnostic mutant-run
17
+ # orchestration (MutantRunSupport) and test selection (RspecTestSelection)
18
+ # but implements its own Minitest-specific test invocation and suite command.
19
+ # Per-test coverage collection is not yet wired into this path.
20
+ class Minitest < Base
21
+ include MutantRunSupport
22
+ include RspecTestSelection
23
+
24
+ DEFAULT_SUITE_TIMEOUT = 300.0
25
+
26
+ def test_files = spec_files
27
+
28
+ def per_test_coverage_supported?
29
+ true
30
+ end
31
+
32
+ def run_mutant(mutant:, test_files:, timeout:)
33
+ MinitestLoadPath.ensure!
34
+ super
35
+ end
36
+
37
+ def spawn_mutant(mutant:, test_files:)
38
+ MinitestLoadPath.ensure!
39
+ super
40
+ end
41
+
42
+ def run_in_child(mutant:, test_files:, log_paths:)
43
+ ENV["RAILS_ENV"] = "test" unless ENV["RAILS_ENV"] == "test"
44
+ RailsEnvironmentPreloader.new.call
45
+ super
46
+ end
47
+
48
+ def run_suite(test_files, timeout: DEFAULT_SUITE_TIMEOUT)
49
+ log_paths = scenario_log_paths("baseline")
50
+ pid = spawn_suite_process(test_files, log_paths)
51
+ wait_result = wait_with_timeout(pid, timeout)
52
+ build_result(wait_result, log_paths)
53
+ ensure
54
+ cleanup_suite_process(pid, wait_result)
55
+ end
56
+
57
+ def suite_command(test_files)
58
+ MinitestSuiteCommand.new.build(test_files)
59
+ end
60
+
61
+ private
62
+
63
+ def run_tests(test_files)
64
+ MinitestTestRunner.new.call(test_files)
65
+ end
66
+
67
+ def subprocess_env
68
+ env = super
69
+ env["RAILS_ENV"] = "test" unless ENV["RAILS_ENV"] == "test"
70
+ env["PARALLEL_WORKERS"] = "1"
71
+ env
72
+ end
73
+
74
+ def spawn_suite_process(test_files, log_paths)
75
+ FileUtils.mkdir_p(File.dirname(log_paths[:stdout_path]))
76
+ File.open(log_paths[:stdout_path], "w") do |stdout_file|
77
+ File.open(log_paths[:stderr_path], "w") do |stderr_file|
78
+ Process.spawn(
79
+ subprocess_env,
80
+ *suite_command(test_files),
81
+ out: stdout_file,
82
+ err: stderr_file
83
+ )
84
+ end
85
+ end
86
+ end
87
+
88
+ def cleanup_suite_process(pid, wait_result)
89
+ return unless pid
90
+
91
+ cleanup_child_process(pid)
92
+ reap_child(pid) if wait_result.nil?
93
+ end
94
+
95
+ def spec_files
96
+ (Dir.glob("test/**/*_test.rb") + Dir.glob("test/**/*_spec.rb"))
97
+ .reject { |f| f.start_with?("test/system/") }
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ module Integration
5
+ # Ensures the current project's test/ directory is on $LOAD_PATH, for
6
+ # Minitest#run_mutant/#spawn_mutant. Idempotent: safe to call repeatedly.
7
+ module MinitestLoadPath
8
+ def self.ensure!
9
+ test_dir = File.expand_path("test")
10
+ $LOAD_PATH.unshift(test_dir) unless $LOAD_PATH.include?(test_dir)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ module Integration
5
+ # Builds the argv used to run Minitest test files in an external Ruby
6
+ # process, for Minitest#spawn_suite_process's baseline coverage bootstrap.
7
+ class MinitestSuiteCommand
8
+ def build(test_files)
9
+ ["bundle", "exec", "ruby", "-I", "test",
10
+ "-r", "henitai/minitest_simplecov",
11
+ "-r", "henitai/minitest_coverage_hook",
12
+ "-e", "ARGV.each { |f| require File.expand_path(f) }",
13
+ *test_files]
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "coverage_suppression"
4
+
5
+ module Henitai
6
+ module Integration
7
+ # Runs Minitest test files in-process and normalizes the result to an
8
+ # exit code, for Minitest#run_tests during a single mutant activation.
9
+ class MinitestTestRunner
10
+ def call(test_files)
11
+ CoverageRuntimeSuppressors.suppress_simplecov!
12
+ suppress_autorun!
13
+ test_files.each { |file| require File.expand_path(file) }
14
+ # @type var empty_args: Array[String]
15
+ empty_args = []
16
+ status = ::Minitest.run(empty_args)
17
+ return status if status.is_a?(Integer)
18
+
19
+ status == true ? 0 : 1
20
+ end
21
+
22
+ private
23
+
24
+ def suppress_autorun!
25
+ require "minitest"
26
+ singleton_class = ::Minitest.singleton_class
27
+ suppressor = @autorun_suppressor ||= Module.new.tap do |mod|
28
+ mod.define_method(:autorun) { nil }
29
+ end
30
+ return if singleton_class.ancestors.include?(suppressor)
31
+
32
+ singleton_class.prepend(suppressor)
33
+ nil
34
+ rescue LoadError, NameError
35
+ nil
36
+ end
37
+ end
38
+ end
39
+ 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,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ module Integration
5
+ # Loads a Rails app's config/environment.rb if present, for
6
+ # Minitest#run_in_child. A no-op outside a Rails project.
7
+ class RailsEnvironmentPreloader
8
+ def call
9
+ env_file = File.expand_path("config/environment.rb")
10
+ require env_file if File.exist?(env_file)
11
+ end
12
+ end
13
+ end
14
+ end