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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +296 -1
- data/README.md +109 -16
- data/assets/schema/henitai.schema.json +20 -0
- data/lib/henitai/arid_node_filter.rb +4 -19
- data/lib/henitai/bootstrap_error_message.rb +20 -0
- data/lib/henitai/canonical_report_merger.rb +23 -2
- data/lib/henitai/cli/run_command.rb +25 -7
- data/lib/henitai/cli/run_options.rb +1 -1
- data/lib/henitai/cli.rb +4 -1
- data/lib/henitai/configuration.rb +4 -1
- data/lib/henitai/configuration_validator/rules.rb +8 -0
- data/lib/henitai/configuration_validator/scalars.rb +9 -0
- data/lib/henitai/configuration_validator.rb +2 -1
- data/lib/henitai/constant_path.rb +48 -0
- data/lib/henitai/control_run.rb +73 -0
- data/lib/henitai/coverage_bootstrapper.rb +57 -15
- data/lib/henitai/coverage_formatter.rb +26 -3
- data/lib/henitai/coverage_report_reader.rb +21 -0
- data/lib/henitai/equivalence_detector/operand_predicates.rb +16 -0
- data/lib/henitai/equivalence_detector.rb +46 -38
- data/lib/henitai/example_summary.rb +24 -0
- data/lib/henitai/execution_engine/fork_server_scope.rb +57 -0
- data/lib/henitai/execution_engine.rb +75 -60
- data/lib/henitai/fidelity_check.rb +173 -0
- data/lib/henitai/flaky_detection_baseline.rb +61 -0
- data/lib/henitai/flaky_mutant_retrier.rb +43 -0
- data/lib/henitai/flaky_retry_decision.rb +23 -0
- data/lib/henitai/flaky_test_registry.rb +63 -0
- data/lib/henitai/fork_server/client.rb +231 -0
- data/lib/henitai/fork_server/group_killer.rb +48 -0
- data/lib/henitai/fork_server/group_terminator.rb +47 -0
- data/lib/henitai/fork_server/launcher.rb +38 -0
- data/lib/henitai/fork_server/mutant_runner.rb +141 -0
- data/lib/henitai/fork_server/protocol.rb +73 -0
- data/lib/henitai/fork_server/reaper.rb +24 -0
- data/lib/henitai/fork_server/runtime.rb +109 -0
- data/lib/henitai/fork_server/server.rb +108 -0
- data/lib/henitai/fork_server/server_process.rb +73 -0
- data/lib/henitai/fork_server/status.rb +52 -0
- data/lib/henitai/fork_server.rb +43 -0
- data/lib/henitai/generated_artifacts.rb +3 -0
- data/lib/henitai/git_diff_analyzer.rb +28 -11
- data/lib/henitai/incremental_filter.rb +20 -10
- data/lib/henitai/integration/base.rb +72 -5
- data/lib/henitai/integration/capped_log_sink.rb +34 -0
- data/lib/henitai/integration/child_bootstrap.rb +7 -0
- data/lib/henitai/integration/child_report_store.rb +36 -0
- data/lib/henitai/integration/minitest.rb +17 -7
- data/lib/henitai/integration/minitest_autorun.rb +20 -0
- data/lib/henitai/integration/minitest_outcome_reporter.rb +28 -0
- data/lib/henitai/integration/minitest_preload.rb +30 -0
- data/lib/henitai/integration/minitest_runnable_filter.rb +61 -0
- data/lib/henitai/integration/minitest_test_runner.rb +32 -15
- data/lib/henitai/integration/mutant_run_support.rb +73 -13
- data/lib/henitai/integration/rspec_child_runner.rb +44 -9
- data/lib/henitai/integration/rspec_preload.rb +35 -0
- data/lib/henitai/integration/rspec_process_runner.rb +4 -1
- data/lib/henitai/integration/scenario_log_support.rb +31 -18
- data/lib/henitai/integration.rb +16 -3
- data/lib/henitai/minitest_coverage_reporter.rb +22 -6
- data/lib/henitai/minitest_result_file.rb +62 -0
- data/lib/henitai/mutant/activation.rb +32 -0
- data/lib/henitai/mutant/activator.rb +75 -55
- data/lib/henitai/mutant/def_injection.rb +136 -0
- data/lib/henitai/mutant/lexical_scope_check.rb +120 -0
- data/lib/henitai/mutant/lexical_target.rb +105 -0
- data/lib/henitai/mutant/replacement.rb +52 -0
- data/lib/henitai/mutant.rb +27 -8
- data/lib/henitai/mutant_generator.rb +62 -18
- data/lib/henitai/mutant_history_store/verdict_cache.rb +12 -11
- data/lib/henitai/mutant_history_store.rb +14 -4
- data/lib/henitai/mutant_identity.rb +5 -1
- data/lib/henitai/mutant_verdict.rb +71 -0
- data/lib/henitai/operators/array_declaration.rb +4 -0
- data/lib/henitai/operators/assignment_expression.rb +2 -0
- data/lib/henitai/operators/conditional_expression.rb +21 -1
- data/lib/henitai/operators/hash_literal.rb +7 -4
- data/lib/henitai/operators/method_expression.rb +14 -3
- data/lib/henitai/operators/regex_metacharacters.rb +89 -0
- data/lib/henitai/operators/regex_mutator.rb +23 -28
- data/lib/henitai/operators/return_value.rb +1 -1
- data/lib/henitai/operators.rb +1 -0
- data/lib/henitai/per_test_coverage.rb +11 -7
- data/lib/henitai/per_test_coverage_collector.rb +30 -31
- data/lib/henitai/per_test_coverage_delta.rb +60 -0
- data/lib/henitai/per_test_coverage_selector.rb +26 -13
- data/lib/henitai/process_wakeup.rb +8 -3
- data/lib/henitai/process_worker_runner.rb +19 -5
- data/lib/henitai/reporter.rb +15 -8
- data/lib/henitai/result.rb +32 -16
- data/lib/henitai/reuse_context.rb +50 -0
- data/lib/henitai/rspec_coverage_formatter.rb +4 -0
- data/lib/henitai/rspec_outcomes.rb +72 -0
- data/lib/henitai/runner.rb +58 -19
- data/lib/henitai/runner_dependencies.rb +22 -1
- data/lib/henitai/sampling_strategy.rb +36 -6
- data/lib/henitai/scenario_execution_result.rb +28 -5
- data/lib/henitai/signature_line.rb +21 -0
- data/lib/henitai/slot_scheduler/draining.rb +12 -2
- data/lib/henitai/slot_scheduler/process_control.rb +33 -0
- data/lib/henitai/slot_scheduler/retry_policy.rb +8 -6
- data/lib/henitai/slot_scheduler.rb +47 -8
- data/lib/henitai/source_file_selection.rb +1 -1
- data/lib/henitai/source_parser.rb +29 -9
- data/lib/henitai/static_filter.rb +45 -14
- data/lib/henitai/stillborn_filter.rb +70 -2
- data/lib/henitai/subject.rb +8 -3
- data/lib/henitai/subject_resolver.rb +65 -31
- data/lib/henitai/suite_boot_clock.rb +48 -0
- data/lib/henitai/survivor_activation_cache.rb +18 -11
- data/lib/henitai/survivor_rerun_strategy.rb +47 -43
- data/lib/henitai/test_file_outcomes.rb +79 -0
- data/lib/henitai/timeout_calibrator.rb +31 -9
- data/lib/henitai/timeout_ceiling.rb +37 -0
- data/lib/henitai/verdict_fingerprint.rb +29 -30
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +17 -1
- data/sig/configuration_validator.rbs +1 -0
- data/sig/henitai.rbs +227 -45
- metadata +46 -3
- 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
|
|
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
|
-
|
|
15
|
-
stdout, stderr, status = git_diff(dir, "--name-only", from, to)
|
|
19
|
+
NAME_ONLY = ["--name-only", "--relative", "-z"].freeze
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
def changed_files(from:, to:, dir: Dir.pwd)
|
|
22
|
+
diff_paths(dir, from, to)
|
|
23
|
+
end
|
|
18
24
|
|
|
19
|
-
|
|
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
|
|
98
|
-
stdout, stderr, status = git_diff(dir,
|
|
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
|
|
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
|
|
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
|
|
18
|
-
#
|
|
19
|
-
# re-
|
|
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,
|
|
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
|
-
@
|
|
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, :
|
|
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?(
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
47
|
+
end
|
|
31
48
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|