henitai 0.5.1 → 0.6.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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +296 -1
  3. data/README.md +109 -16
  4. data/assets/schema/henitai.schema.json +20 -0
  5. data/lib/henitai/arid_node_filter.rb +4 -19
  6. data/lib/henitai/bootstrap_error_message.rb +20 -0
  7. data/lib/henitai/canonical_report_merger.rb +23 -2
  8. data/lib/henitai/cli/run_command.rb +25 -7
  9. data/lib/henitai/cli/run_options.rb +1 -1
  10. data/lib/henitai/cli.rb +4 -1
  11. data/lib/henitai/configuration.rb +4 -1
  12. data/lib/henitai/configuration_validator/rules.rb +8 -0
  13. data/lib/henitai/configuration_validator/scalars.rb +9 -0
  14. data/lib/henitai/configuration_validator.rb +2 -1
  15. data/lib/henitai/constant_path.rb +48 -0
  16. data/lib/henitai/control_run.rb +73 -0
  17. data/lib/henitai/coverage_bootstrapper.rb +57 -15
  18. data/lib/henitai/coverage_formatter.rb +26 -3
  19. data/lib/henitai/coverage_report_reader.rb +21 -0
  20. data/lib/henitai/equivalence_detector/operand_predicates.rb +16 -0
  21. data/lib/henitai/equivalence_detector.rb +46 -38
  22. data/lib/henitai/example_summary.rb +24 -0
  23. data/lib/henitai/execution_engine/fork_server_scope.rb +57 -0
  24. data/lib/henitai/execution_engine.rb +75 -60
  25. data/lib/henitai/fidelity_check.rb +173 -0
  26. data/lib/henitai/flaky_detection_baseline.rb +61 -0
  27. data/lib/henitai/flaky_mutant_retrier.rb +43 -0
  28. data/lib/henitai/flaky_retry_decision.rb +23 -0
  29. data/lib/henitai/flaky_test_registry.rb +63 -0
  30. data/lib/henitai/fork_server/client.rb +231 -0
  31. data/lib/henitai/fork_server/group_killer.rb +48 -0
  32. data/lib/henitai/fork_server/group_terminator.rb +47 -0
  33. data/lib/henitai/fork_server/launcher.rb +38 -0
  34. data/lib/henitai/fork_server/mutant_runner.rb +141 -0
  35. data/lib/henitai/fork_server/protocol.rb +73 -0
  36. data/lib/henitai/fork_server/reaper.rb +24 -0
  37. data/lib/henitai/fork_server/runtime.rb +109 -0
  38. data/lib/henitai/fork_server/server.rb +108 -0
  39. data/lib/henitai/fork_server/server_process.rb +73 -0
  40. data/lib/henitai/fork_server/status.rb +52 -0
  41. data/lib/henitai/fork_server.rb +43 -0
  42. data/lib/henitai/generated_artifacts.rb +3 -0
  43. data/lib/henitai/git_diff_analyzer.rb +28 -11
  44. data/lib/henitai/incremental_filter.rb +20 -10
  45. data/lib/henitai/integration/base.rb +72 -5
  46. data/lib/henitai/integration/capped_log_sink.rb +34 -0
  47. data/lib/henitai/integration/child_bootstrap.rb +7 -0
  48. data/lib/henitai/integration/child_report_store.rb +36 -0
  49. data/lib/henitai/integration/minitest.rb +17 -7
  50. data/lib/henitai/integration/minitest_autorun.rb +20 -0
  51. data/lib/henitai/integration/minitest_outcome_reporter.rb +28 -0
  52. data/lib/henitai/integration/minitest_preload.rb +30 -0
  53. data/lib/henitai/integration/minitest_runnable_filter.rb +61 -0
  54. data/lib/henitai/integration/minitest_test_runner.rb +32 -15
  55. data/lib/henitai/integration/mutant_run_support.rb +73 -13
  56. data/lib/henitai/integration/rspec_child_runner.rb +44 -9
  57. data/lib/henitai/integration/rspec_preload.rb +35 -0
  58. data/lib/henitai/integration/rspec_process_runner.rb +4 -1
  59. data/lib/henitai/integration/scenario_log_support.rb +31 -18
  60. data/lib/henitai/integration.rb +16 -3
  61. data/lib/henitai/minitest_coverage_reporter.rb +22 -6
  62. data/lib/henitai/minitest_result_file.rb +62 -0
  63. data/lib/henitai/mutant/activation.rb +32 -0
  64. data/lib/henitai/mutant/activator.rb +75 -55
  65. data/lib/henitai/mutant/def_injection.rb +136 -0
  66. data/lib/henitai/mutant/lexical_scope_check.rb +120 -0
  67. data/lib/henitai/mutant/lexical_target.rb +105 -0
  68. data/lib/henitai/mutant/replacement.rb +52 -0
  69. data/lib/henitai/mutant.rb +27 -8
  70. data/lib/henitai/mutant_generator.rb +62 -18
  71. data/lib/henitai/mutant_history_store/verdict_cache.rb +12 -11
  72. data/lib/henitai/mutant_history_store.rb +14 -4
  73. data/lib/henitai/mutant_identity.rb +5 -1
  74. data/lib/henitai/mutant_verdict.rb +71 -0
  75. data/lib/henitai/operators/array_declaration.rb +4 -0
  76. data/lib/henitai/operators/assignment_expression.rb +2 -0
  77. data/lib/henitai/operators/conditional_expression.rb +21 -1
  78. data/lib/henitai/operators/hash_literal.rb +7 -4
  79. data/lib/henitai/operators/method_expression.rb +14 -3
  80. data/lib/henitai/operators/regex_metacharacters.rb +89 -0
  81. data/lib/henitai/operators/regex_mutator.rb +23 -28
  82. data/lib/henitai/operators/return_value.rb +1 -1
  83. data/lib/henitai/operators.rb +1 -0
  84. data/lib/henitai/per_test_coverage.rb +11 -7
  85. data/lib/henitai/per_test_coverage_collector.rb +30 -31
  86. data/lib/henitai/per_test_coverage_delta.rb +60 -0
  87. data/lib/henitai/per_test_coverage_selector.rb +26 -13
  88. data/lib/henitai/process_wakeup.rb +8 -3
  89. data/lib/henitai/process_worker_runner.rb +19 -5
  90. data/lib/henitai/reporter.rb +15 -8
  91. data/lib/henitai/result.rb +32 -16
  92. data/lib/henitai/reuse_context.rb +50 -0
  93. data/lib/henitai/rspec_coverage_formatter.rb +4 -0
  94. data/lib/henitai/rspec_outcomes.rb +72 -0
  95. data/lib/henitai/runner.rb +58 -19
  96. data/lib/henitai/runner_dependencies.rb +22 -1
  97. data/lib/henitai/sampling_strategy.rb +36 -6
  98. data/lib/henitai/scenario_execution_result.rb +28 -5
  99. data/lib/henitai/signature_line.rb +21 -0
  100. data/lib/henitai/slot_scheduler/draining.rb +12 -2
  101. data/lib/henitai/slot_scheduler/process_control.rb +33 -0
  102. data/lib/henitai/slot_scheduler/retry_policy.rb +8 -6
  103. data/lib/henitai/slot_scheduler.rb +47 -8
  104. data/lib/henitai/source_file_selection.rb +1 -1
  105. data/lib/henitai/source_parser.rb +29 -9
  106. data/lib/henitai/static_filter.rb +45 -14
  107. data/lib/henitai/stillborn_filter.rb +70 -2
  108. data/lib/henitai/subject.rb +8 -3
  109. data/lib/henitai/subject_resolver.rb +65 -31
  110. data/lib/henitai/suite_boot_clock.rb +48 -0
  111. data/lib/henitai/survivor_activation_cache.rb +18 -11
  112. data/lib/henitai/survivor_rerun_strategy.rb +47 -43
  113. data/lib/henitai/test_file_outcomes.rb +79 -0
  114. data/lib/henitai/timeout_calibrator.rb +31 -9
  115. data/lib/henitai/timeout_ceiling.rb +37 -0
  116. data/lib/henitai/verdict_fingerprint.rb +29 -30
  117. data/lib/henitai/version.rb +1 -1
  118. data/lib/henitai.rb +17 -1
  119. data/sig/configuration_validator.rbs +1 -0
  120. data/sig/henitai.rbs +227 -45
  121. metadata +46 -3
  122. data/lib/henitai/survivor_test_filter.rb +0 -72
@@ -9,14 +9,23 @@ module Henitai
9
9
  # Shells out to git to discover changed files between two refs.
10
10
  #
11
11
  # By default the analyzer runs in the current working directory. Callers can
12
- # pass dir: to point it at another repository root without changing cwd.
12
+ # pass dir: to point it at another directory without changing cwd.
13
+ #
14
+ # Every path is reported relative to that directory, which need not be the
15
+ # repository root: `git diff` would otherwise print root-relative paths
16
+ # while `git ls-files` prints directory-relative ones. Paths are read
17
+ # NUL-separated so git never quotes non-ASCII names.
13
18
  class GitDiffAnalyzer
14
- def changed_files(from:, to:, dir: Dir.pwd)
15
- stdout, stderr, status = git_diff(dir, "--name-only", from, to)
19
+ NAME_ONLY = ["--name-only", "--relative", "-z"].freeze
16
20
 
17
- raise GitDiffError, stderr.strip unless status.success?
21
+ def changed_files(from:, to:, dir: Dir.pwd)
22
+ diff_paths(dir, from, to)
23
+ end
18
24
 
19
- stdout.split("\n").reject(&:empty?)
25
+ # Changes on HEAD since its merge base with ref. Commits that landed on
26
+ # ref after the fork point are not part of this diff.
27
+ def changed_files_since(ref, dir: Dir.pwd)
28
+ diff_paths(dir, "#{ref}...HEAD")
20
29
  end
21
30
 
22
31
  def working_tree_changed_files(dir: Dir.pwd)
@@ -55,7 +64,7 @@ module Henitai
55
64
  end
56
65
 
57
66
  def changed_line_ranges(path, from:, to:, dir:)
58
- stdout, stderr, status = git_diff(dir, "--unified=0", from, to, "--", path)
67
+ stdout, stderr, status = git_diff(dir, "--unified=0", "--relative", from, to, "--", path)
59
68
 
60
69
  raise GitDiffError, stderr.strip unless status.success?
61
70
 
@@ -94,23 +103,31 @@ module Henitai
94
103
  Open3.capture3(*command)
95
104
  end
96
105
 
97
- def working_tree_tracked_files(dir)
98
- stdout, stderr, status = git_diff(dir, "--name-only", "HEAD")
106
+ def diff_paths(dir, *revisions)
107
+ stdout, stderr, status = git_diff(dir, *NAME_ONLY, *revisions)
99
108
 
100
109
  raise GitDiffError, stderr.strip unless status.success?
101
110
 
102
- stdout.split("\n").reject(&:empty?)
111
+ paths(stdout)
112
+ end
113
+
114
+ def working_tree_tracked_files(dir)
115
+ diff_paths(dir, "HEAD")
103
116
  end
104
117
 
105
118
  def untracked_files(dir)
106
119
  command = ["git"]
107
120
  command += ["-C", dir] if dir
108
- command += ["ls-files", "--others", "--exclude-standard"]
121
+ command += ["ls-files", "--others", "--exclude-standard", "-z"]
109
122
  stdout, stderr, status = Open3.capture3(*command)
110
123
 
111
124
  raise GitDiffError, stderr.strip unless status.success?
112
125
 
113
- stdout.split("\n").reject(&:empty?)
126
+ paths(stdout)
127
+ end
128
+
129
+ def paths(stdout)
130
+ stdout.dup.force_encoding(Encoding::UTF_8).split("\0").reject(&:empty?)
114
131
  end
115
132
  end
116
133
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "signature_line"
3
4
  require_relative "verdict_fingerprint"
4
5
 
5
6
  module Henitai
@@ -8,23 +9,26 @@ module Henitai
8
9
  #
9
10
  # Reuse rules (conservative — any doubt resolves to re-execution):
10
11
  #
12
+ # * Both verdicts: the stored {ReuseContext} fingerprint (test
13
+ # dependencies, verdict-relevant configuration, henitai version) must
14
+ # equal the current one. Rows recorded without a context are never
15
+ # reused.
11
16
  # * Killed: the stored verdict must carry both fingerprints, and both the
12
17
  # subject's source and every recorded covering test file must be
13
18
  # byte-identical to what was recorded. Killed is monotone, so no live
14
19
  # coverage is needed.
15
20
  # * Survived: additionally the LIVE covering set (the full-map
16
21
  # intersection computed via {PerTestCoverage}) must equal the recorded
17
- # set in membership, and the run-level dependency fingerprint must be
18
- # unchanged — a new or edited test reaching the mutant always forces a
19
- # re-run (ADR-11). Without a live per-test map or dependency
20
- # fingerprint, survivors always re-execute.
22
+ # set in membership — a new or edited test reaching the mutant always
23
+ # forces a re-run (ADR-11). Without a live per-test map or a context,
24
+ # survivors always re-execute.
21
25
  #
22
26
  # Timeouts, errors, unknown ids and legacy rows always re-execute.
23
27
  class IncrementalFilter
24
- def initialize(history_store:, per_test_coverage: nil, dependency_fingerprint: nil)
28
+ def initialize(history_store:, per_test_coverage: nil, reuse_context: nil)
25
29
  @history_store = history_store
26
30
  @per_test_coverage = per_test_coverage
27
- @dependency_fingerprint = dependency_fingerprint
31
+ @reuse_context = reuse_context
28
32
  end
29
33
 
30
34
  # @return [Array<Mutant>] the same collection; cache hits get the stored
@@ -48,7 +52,7 @@ module Henitai
48
52
 
49
53
  private
50
54
 
51
- attr_reader :history_store, :per_test_coverage, :dependency_fingerprint
55
+ attr_reader :history_store, :per_test_coverage, :reuse_context
52
56
 
53
57
  # MutantIdentity deliberately omits source coordinates (line-drift
54
58
  # tolerance), so distinct mutants inside one subject can share a stable
@@ -75,7 +79,9 @@ module Henitai
75
79
 
76
80
  def killed_reusable?(mutant, verdict)
77
81
  subject_source_unchanged?(mutant, verdict) &&
78
- VerdictFingerprint.tests_fingerprint_current?(verdict.fetch(:covered_tests_fingerprint))
82
+ VerdictFingerprint.tests_fingerprint_current?(
83
+ verdict.fetch(:covered_tests_fingerprint), context_sha: context_sha
84
+ )
79
85
  end
80
86
 
81
87
  # The filter checks per-test map availability itself instead of trusting
@@ -84,17 +90,21 @@ module Henitai
84
90
  # unknowable means re-execute.
85
91
  def survived_reusable?(mutant, verdict)
86
92
  return false unless per_test_coverage&.available?
93
+ return false if SignatureLine.on?(mutant)
87
94
  return false unless subject_source_unchanged?(mutant, verdict)
88
95
 
89
96
  VerdictFingerprint.survivor_fingerprint_current?(
90
97
  verdict.fetch(:covered_tests_fingerprint),
91
98
  live_paths: per_test_coverage.tests_covering(mutant),
92
- dependency_sha: dependency_fingerprint
99
+ context_sha: context_sha
93
100
  )
94
101
  end
95
102
 
103
+ def context_sha = reuse_context&.fingerprint
104
+
96
105
  def subject_source_unchanged?(mutant, verdict)
97
- VerdictFingerprint.subject_source_hash(mutant) == verdict.fetch(:subject_source_hash)
106
+ current = VerdictFingerprint.subject_source_hash(mutant)
107
+ !current.nil? && current == verdict.fetch(:subject_source_hash)
98
108
  end
99
109
  end
100
110
  end
@@ -15,6 +15,9 @@ module Henitai
15
15
  class Base
16
16
  include ChildRuntimeControl
17
17
 
18
+ EXITED_GROUP_POLL_INTERVAL = 0.05
19
+ EXITED_GROUP_POLL_COUNT = 40
20
+
18
21
  # Child-run diagnostics. Public so the child-side modules mixed into
19
22
  # concrete adapters can reach it without `send`.
20
23
  def child_debug_log
@@ -57,6 +60,20 @@ module Henitai
57
60
  false
58
61
  end
59
62
 
63
+ # True when the integration can boot once in a fork server (ADR-15) and
64
+ # run mutants from the booted state.
65
+ def fork_server_supported?
66
+ false
67
+ end
68
+
69
+ # Loads the test environment and the given test files without running
70
+ # anything; only called inside the fork server process.
71
+ #
72
+ # @param test_files [Array<String>]
73
+ def preload_environment(test_files)
74
+ raise NotImplementedError
75
+ end
76
+
60
77
  def wait_with_timeout(pid, timeout)
61
78
  wakeup = Henitai.const_get(:ProcessWakeup).new.install
62
79
  return Process.last_status if wait_nonblocking(pid)
@@ -78,6 +95,29 @@ module Henitai
78
95
  end
79
96
 
80
97
  def cleanup_process_group(pid)
98
+ if leader_exited?(pid)
99
+ terminate_exited_group(pid)
100
+ else
101
+ terminate_running_group(pid)
102
+ end
103
+ rescue Errno::EPERM
104
+ cleanup_child_process(pid)
105
+ rescue Errno::ESRCH
106
+ nil
107
+ end
108
+
109
+ private
110
+
111
+ # A leader that already exited normally cannot be waited on again, so
112
+ # the group is polled for members instead of burning the grace period.
113
+ def terminate_exited_group(pid)
114
+ Process.kill(:SIGTERM, -pid)
115
+ return unless group_members_remain?(pid)
116
+
117
+ Process.kill(:SIGKILL, -pid)
118
+ end
119
+
120
+ def terminate_running_group(pid)
81
121
  grace_period = 2.0
82
122
  wakeup = Henitai.const_get(:ProcessWakeup).new.install
83
123
  Process.kill(:SIGTERM, -pid)
@@ -88,15 +128,34 @@ module Henitai
88
128
  return if wait_nonblocking(pid)
89
129
 
90
130
  Process.kill(:SIGKILL, -pid)
91
- rescue Errno::EPERM
92
- cleanup_child_process(pid)
93
- rescue Errno::ESRCH
94
- nil
95
131
  ensure
96
132
  wakeup&.close
97
133
  end
98
134
 
99
- private
135
+ def leader_exited?(pid)
136
+ !Process.wait(pid, Process::WNOHANG).nil?
137
+ rescue Errno::ECHILD, Errno::ESRCH
138
+ true
139
+ end
140
+
141
+ def group_members_remain?(pid)
142
+ EXITED_GROUP_POLL_COUNT.times do
143
+ return false unless group_alive?(pid)
144
+
145
+ pause(EXITED_GROUP_POLL_INTERVAL)
146
+ end
147
+ group_alive?(pid)
148
+ end
149
+
150
+ # Signal 0 probes existence only: ESRCH means the group is empty.
151
+ def group_alive?(pid)
152
+ Process.kill(0, -pid)
153
+ true
154
+ rescue Errno::ESRCH
155
+ false
156
+ rescue Errno::EPERM
157
+ true
158
+ end
100
159
 
101
160
  def pause(seconds)
102
161
  sleep(seconds)
@@ -133,6 +192,10 @@ module Henitai
133
192
  { "PARALLEL_WORKERS" => "1" }
134
193
  end
135
194
 
195
+ def suite_process_env
196
+ subprocess_env.merge(SuiteBootClock::STARTED_AT_ENV => Time.now.to_f.to_s)
197
+ end
198
+
136
199
  def wait_nonblocking(pid)
137
200
  Process.wait(pid, Process::WNOHANG)
138
201
  rescue Errno::ECHILD, Errno::ESRCH
@@ -143,6 +206,10 @@ module Henitai
143
206
  @scenario_log_support ||= ScenarioLogSupport.new
144
207
  end
145
208
 
209
+ def child_report_store
210
+ @child_report_store ||= ChildReportStore.new
211
+ end
212
+
146
213
  def with_subprocess_env
147
214
  original_env = {} # : Hash[String, String?]
148
215
  subprocess_env.each do |key, value|
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ module Integration
5
+ # Writes captured child output to a log file up to a byte cap, then
6
+ # discards the overflow and leaves a single truncation marker behind.
7
+ # Not interrupt-safe: the drain thread must be stopped cooperatively,
8
+ # never killed mid-write.
9
+ class CappedLogSink
10
+ def initialize(file, cap)
11
+ @file = file
12
+ @cap = cap
13
+ @written = 0
14
+ @truncated = false
15
+ end
16
+
17
+ def write(chunk)
18
+ return unless @written < @cap
19
+
20
+ slice = chunk.byteslice(0, @cap - @written)
21
+ @file.write(slice)
22
+ @written += slice.bytesize
23
+ write_truncation_marker if @written >= @cap && !@truncated
24
+ end
25
+
26
+ private
27
+
28
+ def write_truncation_marker
29
+ @file.write("\n[henitai] output truncated at #{@cap} bytes\n")
30
+ @truncated = true
31
+ end
32
+ end
33
+ end
34
+ end
@@ -9,6 +9,12 @@ module Henitai
9
9
  # without forking, and so both the RSpec and Minitest paths -- which share
10
10
  # MutantRunSupport#spawn_mutant -- get identical treatment.
11
11
  module ChildBootstrap
12
+ # Signals the parent traps for its own shutdown (INT/TERM/HUP) and for
13
+ # its event-loop wakeup (CHLD). A child that kept those handlers would
14
+ # ignore the SIGTERM sent while draining it and could write into the
15
+ # parent's wakeup pipe.
16
+ INHERITED_TRAPS = %w[INT TERM HUP CHLD].freeze
17
+
12
18
  # @param parent_pid [Integer] captured in the parent *before* Process.fork.
13
19
  # Reading Process.ppid here instead would race the very death the
14
20
  # watchdog is looking for: a parent that dies between fork and this
@@ -18,6 +24,7 @@ module Henitai
18
24
  # First, so that even a crash later in this method releases the
19
25
  # reports-directory lock rather than pinning it with an inherited fd.
20
26
  InheritedFdRegistry.close_all!
27
+ INHERITED_TRAPS.each { |signal| Signal.trap(signal, "DEFAULT") }
21
28
  Process.setpgid(0, 0)
22
29
  OrphanWatchdog.start(parent_pid:)
23
30
  nil
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module Henitai
7
+ module Integration
8
+ # Carries a mutant child's report to the parent through a small JSON file
9
+ # next to the child's logs. The parent clears the file before each fork so
10
+ # a retry can never read the previous attempt's report.
11
+ class ChildReportStore
12
+ def write(path, data)
13
+ return unless path
14
+
15
+ FileUtils.mkdir_p(File.dirname(path))
16
+ temp_path = "#{path}.#{Process.pid}.tmp"
17
+ File.write(temp_path, JSON.generate(data))
18
+ File.rename(temp_path, path)
19
+ nil
20
+ end
21
+
22
+ def read(path)
23
+ return unless path && File.exist?(path)
24
+
25
+ JSON.parse(File.read(path))
26
+ rescue JSON::ParserError
27
+ nil
28
+ end
29
+
30
+ def clear(path)
31
+ FileUtils.rm_f(path) if path
32
+ nil
33
+ end
34
+ end
35
+ end
36
+ end
@@ -5,6 +5,7 @@ require "fileutils"
5
5
  # tail, by which point Base is already defined, so the require is skipped.
6
6
  require_relative "../integration" unless defined?(Henitai::Integration::Base)
7
7
  require_relative "minitest_load_path"
8
+ require_relative "minitest_preload"
8
9
  require_relative "minitest_suite_command"
9
10
  require_relative "minitest_test_runner"
10
11
  require_relative "rails_environment_preloader"
@@ -18,6 +19,7 @@ module Henitai
18
19
  # but implements its own Minitest-specific test invocation and suite command.
19
20
  # Per-test coverage collection is not yet wired into this path.
20
21
  class Minitest < Base
22
+ include MinitestPreload
21
23
  include MutantRunSupport
22
24
  include RspecTestSelection
23
25
 
@@ -60,8 +62,8 @@ module Henitai
60
62
 
61
63
  private
62
64
 
63
- def run_tests(test_files)
64
- MinitestTestRunner.new.call(test_files)
65
+ def run_tests(test_files, outcomes, &)
66
+ MinitestTestRunner.new(preloaded: @minitest_preloaded == true).call(test_files, outcomes, &)
65
67
  end
66
68
 
67
69
  def subprocess_env
@@ -71,30 +73,38 @@ module Henitai
71
73
  env
72
74
  end
73
75
 
76
+ # `pgroup: true` makes the suite child a group leader, so the whole tree
77
+ # it spawns -- a Rails test server, browser drivers -- is signalable as
78
+ # one group. Without it the parent's `kill(:SIGTERM, -pid)` addresses a
79
+ # group that does not exist, raises ESRCH, and leaves a timed-out suite
80
+ # running to completion while the parent has already given up on it.
74
81
  def spawn_suite_process(test_files, log_paths)
75
82
  FileUtils.mkdir_p(File.dirname(log_paths[:stdout_path]))
76
83
  File.open(log_paths[:stdout_path], "w") do |stdout_file|
77
84
  File.open(log_paths[:stderr_path], "w") do |stderr_file|
78
85
  Process.spawn(
79
- subprocess_env,
86
+ suite_process_env,
80
87
  *suite_command(test_files),
81
88
  out: stdout_file,
82
- err: stderr_file
89
+ err: stderr_file,
90
+ pgroup: true
83
91
  )
84
92
  end
85
93
  end
86
94
  end
87
95
 
96
+ # A timeout has already torn the group down and reaped the child in
97
+ # `handle_timeout`; signalling again would only race a recycled pid.
88
98
  def cleanup_suite_process(pid, wait_result)
89
99
  return unless pid
90
100
 
91
- cleanup_child_process(pid)
101
+ cleanup_process_group(pid) unless wait_result == :timeout
92
102
  reap_child(pid) if wait_result.nil?
93
103
  end
94
104
 
95
105
  def spec_files
96
- (Dir.glob("test/**/*_test.rb") + Dir.glob("test/**/*_spec.rb"))
97
- .reject { |f| f.start_with?("test/system/") }
106
+ @spec_files ||= (Dir.glob("test/**/*_test.rb") + Dir.glob("test/**/*_spec.rb"))
107
+ .reject { |f| f.start_with?("test/system/") }
98
108
  end
99
109
  end
100
110
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ module Integration
5
+ # Turns Minitest.autorun into a no-op, so that requiring test files never
6
+ # registers the at_exit hook that would run the suite a second time.
7
+ module MinitestAutorun
8
+ SUPPRESSOR = Module.new { def autorun = nil }
9
+
10
+ def self.suppress!
11
+ require "minitest"
12
+ singleton_class = ::Minitest.singleton_class
13
+ singleton_class.prepend(SUPPRESSOR) unless singleton_class.ancestors.include?(SUPPRESSOR)
14
+ nil
15
+ rescue LoadError, NameError
16
+ nil
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest"
4
+ require "henitai/test_file_outcomes"
5
+ require "henitai/minitest_result_file"
6
+
7
+ module Henitai
8
+ module Integration
9
+ # Records the outcome of each finished Minitest result per test file.
10
+ class MinitestOutcomeReporter < ::Minitest::Reporter
11
+ def initialize(outcomes, report_path: nil, io: $stdout)
12
+ super(io, {})
13
+ @outcomes = outcomes
14
+ @report_path = report_path
15
+ end
16
+
17
+ def record(result)
18
+ super
19
+ MinitestResultFile.record(@outcomes, result)
20
+ end
21
+
22
+ def report
23
+ super
24
+ @outcomes.write(@report_path) if @report_path
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "minitest_autorun"
4
+ require_relative "minitest_load_path"
5
+ require_relative "rails_environment_preloader"
6
+
7
+ module Henitai
8
+ module Integration
9
+ # Fork-server support for the Minitest integration (ADR-15): the server
10
+ # loads the environment and the union of the run's test files once, without
11
+ # running anything; each mutant process then runs only its own files.
12
+ module MinitestPreload
13
+ def fork_server_supported?
14
+ true
15
+ end
16
+
17
+ def preload_environment(test_files)
18
+ MinitestAutorun.suppress!
19
+ CoverageRuntimeSuppressors.suppress_simplecov!
20
+ CoverageRuntimeSuppressors.suppress_coverage!
21
+ subprocess_env.each { |key, value| ENV[key] = value }
22
+ MinitestLoadPath.ensure!
23
+ RailsEnvironmentPreloader.new.call
24
+ test_files.each { |file| require File.expand_path(file) }
25
+ @minitest_preloaded = true
26
+ nil
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "henitai/minitest_result_file"
4
+
5
+ module Henitai
6
+ module Integration
7
+ # Narrows Minitest's registry of test classes to those defined in the given
8
+ # test files, for a process that inherited the union of a run's test files
9
+ # from the fork server (ADR-15). A class belongs to a file when one of its
10
+ # test methods is defined there.
11
+ class MinitestRunnableFilter
12
+ def initialize(test_files)
13
+ @wanted = test_files.to_set { |file| canonical(file) }
14
+ end
15
+
16
+ def apply!
17
+ with_seed { ::Minitest::Runnable.runnables.select! { |suite| defined_in_wanted_file?(suite) } }
18
+ end
19
+
20
+ private
21
+
22
+ # runnable_methods shuffles with Minitest.seed, which is unset before a run.
23
+ def with_seed
24
+ original = ::Minitest.seed
25
+ ::Minitest.seed ||= 0
26
+ yield
27
+ ensure
28
+ ::Minitest.seed = original
29
+ end
30
+
31
+ # A suite belongs to a file when its class is defined there, or when one
32
+ # of its test methods is. The class file covers tests inherited from a
33
+ # shared module, which report the module's file; the method check covers
34
+ # a class reopened in another file.
35
+ def defined_in_wanted_file?(suite)
36
+ wanted_class_file?(suite) || method_in_wanted_file?(suite)
37
+ rescue NotImplementedError, NameError
38
+ false
39
+ end
40
+
41
+ def wanted_class_file?(suite)
42
+ file = suite.name && MinitestResultFile.class_file_of(suite.name)
43
+ !file.nil? && @wanted.include?(canonical(file))
44
+ end
45
+
46
+ def method_in_wanted_file?(suite)
47
+ suite.runnable_methods.any? do |name|
48
+ location = suite.instance_method(name).source_location
49
+ location && @wanted.include?(canonical(location.first))
50
+ end
51
+ end
52
+
53
+ # Symlinked temp or checkout directories must not make one file two paths.
54
+ def canonical(path)
55
+ File.realpath(path)
56
+ rescue SystemCallError
57
+ File.expand_path(path)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -1,16 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "coverage_suppression"
3
+ require "henitai/integration/minitest_autorun"
4
+ require "henitai/integration/minitest_outcome_reporter"
5
+ require "henitai/integration/minitest_runnable_filter"
6
+ require "henitai/test_file_outcomes"
4
7
 
5
8
  module Henitai
6
9
  module Integration
7
10
  # Runs Minitest test files in-process and normalizes the result to an
8
11
  # exit code, for Minitest#run_tests during a single mutant activation.
9
12
  class MinitestTestRunner
10
- def call(test_files)
11
- CoverageRuntimeSuppressors.suppress_simplecov!
12
- suppress_autorun!
13
- test_files.each { |file| require File.expand_path(file) }
13
+ PLUGIN_NAME = "henitai_outcomes"
14
+
15
+ # +preloaded+: the fork server already required the union of the run's
16
+ # test files, so only the ones requested here are kept to run.
17
+ def initialize(preloaded: false)
18
+ @preloaded = preloaded
19
+ end
20
+
21
+ # Yields once the test files are required and before Minitest runs; a
22
+ # falsy block result skips the run. Per-file outcomes are recorded into
23
+ # +outcomes+ through a Minitest reporter plugin.
24
+ def call(test_files, outcomes = TestFileOutcomes.new, &after_load)
25
+ MinitestAutorun.suppress!
26
+ load_tests(test_files)
27
+ return 1 if after_load && !after_load.call
28
+
29
+ install_outcome_plugin(outcomes)
14
30
  # @type var empty_args: Array[String]
15
31
  empty_args = []
16
32
  status = ::Minitest.run(empty_args)
@@ -21,18 +37,19 @@ module Henitai
21
37
 
22
38
  private
23
39
 
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 }
40
+ def install_outcome_plugin(outcomes)
41
+ ::Minitest.extensions << PLUGIN_NAME unless ::Minitest.extensions.include?(PLUGIN_NAME)
42
+ init = :"plugin_#{PLUGIN_NAME}_init"
43
+ ::Minitest.singleton_class.send(:remove_method, init) if ::Minitest.respond_to?(init)
44
+ ::Minitest.define_singleton_method(init) do |_options|
45
+ reporter.reporters << MinitestOutcomeReporter.new(outcomes)
29
46
  end
30
- return if singleton_class.ancestors.include?(suppressor)
47
+ end
31
48
 
32
- singleton_class.prepend(suppressor)
33
- nil
34
- rescue LoadError, NameError
35
- nil
49
+ def load_tests(test_files)
50
+ return MinitestRunnableFilter.new(test_files).apply! if @preloaded
51
+
52
+ test_files.each { |file| require File.expand_path(file) }
36
53
  end
37
54
  end
38
55
  end