henitai 0.2.0 → 0.2.1
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 +26 -1
- data/README.md +15 -3
- data/assets/schema/henitai.schema.json +6 -0
- data/lib/henitai/cli/clean_command.rb +48 -0
- data/lib/henitai/cli/command_support.rb +51 -0
- data/lib/henitai/cli/init_command.rb +64 -0
- data/lib/henitai/cli/operator_command.rb +95 -0
- data/lib/henitai/cli/options.rb +120 -0
- data/lib/henitai/cli/run_command.rb +103 -0
- data/lib/henitai/cli.rb +16 -404
- data/lib/henitai/configuration.rb +2 -1
- data/lib/henitai/configuration_validator/rules.rb +143 -0
- data/lib/henitai/configuration_validator/scalars.rb +123 -0
- data/lib/henitai/configuration_validator.rb +12 -239
- data/lib/henitai/eager_load.rb +36 -5
- data/lib/henitai/execution_engine.rb +4 -3
- data/lib/henitai/integration/base.rb +171 -0
- data/lib/henitai/integration/child_debug_support.rb +115 -0
- data/lib/henitai/integration/child_runtime_control.rb +50 -0
- data/lib/henitai/integration/coverage_suppression.rb +43 -0
- data/lib/henitai/integration/minitest.rb +133 -0
- data/lib/henitai/integration/mutant_run_support.rb +77 -0
- data/lib/henitai/integration/rspec_child_runner.rb +61 -0
- data/lib/henitai/integration/rspec_test_selection.rb +135 -0
- data/lib/henitai/integration/scenario_log_support.rb +116 -0
- data/lib/henitai/integration.rb +22 -846
- data/lib/henitai/mutant/activator.rb +1 -79
- data/lib/henitai/mutant/parameter_source.rb +98 -0
- data/lib/henitai/mutant.rb +1 -0
- data/lib/henitai/mutant_history_store/sql.rb +72 -0
- data/lib/henitai/mutant_history_store.rb +5 -69
- data/lib/henitai/per_test_coverage_collector.rb +3 -1
- data/lib/henitai/process_worker_runner.rb +48 -334
- data/lib/henitai/reporter.rb +20 -8
- data/lib/henitai/result.rb +17 -15
- data/lib/henitai/runner.rb +59 -182
- data/lib/henitai/slot_scheduler/draining.rb +140 -0
- data/lib/henitai/slot_scheduler/process_control.rb +43 -0
- data/lib/henitai/slot_scheduler.rb +214 -0
- data/lib/henitai/survivor_rerun_strategy.rb +195 -0
- data/lib/henitai/unparse_helper.rb +5 -2
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +2 -0
- data/sig/configuration_validator.rbs +46 -22
- data/sig/henitai.rbs +158 -73
- metadata +25 -2
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
module ConfigurationValidator
|
|
5
|
+
# Leaf validators for individual configuration values.
|
|
6
|
+
#
|
|
7
|
+
# Each method returns silently for an acceptable value or raises
|
|
8
|
+
# +Henitai::ConfigurationError+ via {Rules.configuration_error}.
|
|
9
|
+
module Scalars
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def validate_operator(value)
|
|
13
|
+
return if value.nil?
|
|
14
|
+
|
|
15
|
+
operator = value.respond_to?(:to_sym) ? value.to_sym : nil
|
|
16
|
+
return if VALID_OPERATORS.include?(operator)
|
|
17
|
+
|
|
18
|
+
Rules.configuration_error(
|
|
19
|
+
"Invalid configuration value for mutation.operators: expected one of " \
|
|
20
|
+
"#{VALID_OPERATORS.join(', ')}, got #{value.inspect}"
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def validate_timeout(value)
|
|
25
|
+
return if value.nil?
|
|
26
|
+
return if value.is_a?(Numeric)
|
|
27
|
+
|
|
28
|
+
Rules.configuration_error(
|
|
29
|
+
"Invalid configuration value for mutation.timeout: expected Numeric, got #{value.class}"
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validate_threshold(value, path)
|
|
34
|
+
return if value.is_a?(Integer) && value.between?(0, 100)
|
|
35
|
+
|
|
36
|
+
Rules.configuration_error(
|
|
37
|
+
"Invalid configuration value for #{path}: expected Integer between 0 and 100, " \
|
|
38
|
+
"got #{value.inspect}"
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def validate_boolean(value, path)
|
|
43
|
+
return if [true, false].include?(value)
|
|
44
|
+
|
|
45
|
+
Rules.configuration_error(
|
|
46
|
+
"Invalid configuration value for #{path}: expected true or false, got #{value.inspect}"
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def validate_optional_string(value, path)
|
|
51
|
+
return if value.nil?
|
|
52
|
+
return if value.is_a?(String)
|
|
53
|
+
|
|
54
|
+
Rules.configuration_error("Invalid configuration value for #{path}: expected String, got #{value.class}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_string_array(value, path)
|
|
58
|
+
return if value.nil?
|
|
59
|
+
return if value.is_a?(Array) && value.all?(String)
|
|
60
|
+
|
|
61
|
+
Rules.configuration_error(
|
|
62
|
+
"Invalid configuration value for #{path}: expected Array<String>, got #{describe_array_type(value)}"
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validate_ignore_patterns(value)
|
|
67
|
+
Array(value).each do |pattern|
|
|
68
|
+
Regexp.new(pattern)
|
|
69
|
+
rescue RegexpError => e
|
|
70
|
+
Rules.configuration_error(
|
|
71
|
+
"Invalid configuration value for mutation.ignore_patterns: " \
|
|
72
|
+
"invalid regular expression #{pattern.inspect}: #{e.message}"
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validate_max_flaky_retries(value)
|
|
78
|
+
return if value.nil?
|
|
79
|
+
return if value.is_a?(Integer) && value >= 0
|
|
80
|
+
|
|
81
|
+
Rules.configuration_error(
|
|
82
|
+
"Invalid configuration value for mutation.max_flaky_retries: expected Integer >= 0, got #{value.inspect}"
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate_sampling_ratio(value)
|
|
87
|
+
return if value.nil?
|
|
88
|
+
return if value.is_a?(Numeric) && value >= 0.0 && value <= 1.0
|
|
89
|
+
|
|
90
|
+
Rules.configuration_error(
|
|
91
|
+
"Invalid configuration value for mutation.sampling.ratio: " \
|
|
92
|
+
"expected Numeric between 0 and 1, got #{value.inspect}"
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def validate_sampling_strategy(value)
|
|
97
|
+
return if value.nil?
|
|
98
|
+
|
|
99
|
+
strategy = value.respond_to?(:to_sym) ? value.to_sym : nil
|
|
100
|
+
return if strategy == :stratified
|
|
101
|
+
|
|
102
|
+
Rules.configuration_error(
|
|
103
|
+
"Invalid configuration value for mutation.sampling.strategy: expected stratified, got #{value.inspect}"
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def validate_sampling_completeness(value)
|
|
108
|
+
return if value.key?(:ratio) && value.key?(:strategy)
|
|
109
|
+
|
|
110
|
+
Rules.configuration_error(
|
|
111
|
+
"Invalid configuration value for mutation.sampling: expected both ratio and strategy"
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def describe_array_type(value)
|
|
116
|
+
return value.class.name unless value.is_a?(Array)
|
|
117
|
+
|
|
118
|
+
element_types = value.map { |item| item.class.name }.uniq.join(", ")
|
|
119
|
+
"Array<#{element_types}>"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "configuration_validator/rules"
|
|
4
|
+
|
|
3
5
|
module Henitai
|
|
4
|
-
# rubocop:disable Metrics/ModuleLength
|
|
5
6
|
# Internal validator for configuration data loaded from YAML and CLI overrides.
|
|
7
|
+
#
|
|
8
|
+
# The public entry point is {.validate!}; the individual rules live in
|
|
9
|
+
# {Rules}. Unknown-key warnings are emitted via {.warn} so that callers (and
|
|
10
|
+
# specs) can observe them on this module.
|
|
6
11
|
module ConfigurationValidator
|
|
7
12
|
VALID_TOP_LEVEL_KEYS = %i[
|
|
8
13
|
integration
|
|
9
14
|
includes
|
|
15
|
+
excludes
|
|
10
16
|
mutation
|
|
11
17
|
coverage_criteria
|
|
12
18
|
thresholds
|
|
@@ -27,6 +33,7 @@ module Henitai
|
|
|
27
33
|
validate_top_level_keys
|
|
28
34
|
validate_integration
|
|
29
35
|
validate_includes
|
|
36
|
+
validate_excludes
|
|
30
37
|
validate_jobs
|
|
31
38
|
validate_reporters
|
|
32
39
|
validate_reports_dir
|
|
@@ -38,246 +45,12 @@ module Henitai
|
|
|
38
45
|
].freeze
|
|
39
46
|
|
|
40
47
|
def self.validate!(raw)
|
|
41
|
-
ensure_hash!(raw, "configuration")
|
|
42
|
-
VALIDATION_STEPS.each { |step|
|
|
48
|
+
Rules.ensure_hash!(raw, "configuration")
|
|
49
|
+
VALIDATION_STEPS.each { |step| Rules.public_send(step, raw) }
|
|
43
50
|
end
|
|
44
51
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def validate_top_level_keys(raw)
|
|
49
|
-
warn_unknown_keys(raw, VALID_TOP_LEVEL_KEYS)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def validate_integration(raw)
|
|
53
|
-
value = raw[:integration]
|
|
54
|
-
return if value.nil?
|
|
55
|
-
return if value.is_a?(String)
|
|
56
|
-
|
|
57
|
-
ensure_hash!(value, "integration")
|
|
58
|
-
warn_unknown_keys(value, VALID_INTEGRATION_KEYS, "integration")
|
|
59
|
-
validate_optional_string(value[:name], "integration.name")
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def validate_includes(raw)
|
|
63
|
-
validate_string_array(raw[:includes], "includes")
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def validate_jobs(raw)
|
|
67
|
-
value = raw[:jobs]
|
|
68
|
-
return if value.nil?
|
|
69
|
-
return if value.is_a?(Integer)
|
|
70
|
-
|
|
71
|
-
configuration_error("Invalid configuration value for jobs: expected Integer, got #{value.class}")
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def validate_reporters(raw)
|
|
75
|
-
validate_string_array(raw[:reporters], "reporters")
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def validate_reports_dir(raw)
|
|
79
|
-
validate_optional_string(raw[:reports_dir], "reports_dir")
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def validate_all_logs(raw)
|
|
83
|
-
value = raw[:all_logs]
|
|
84
|
-
return if value.nil?
|
|
85
|
-
|
|
86
|
-
validate_boolean(value, "all_logs")
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
def validate_dashboard(raw)
|
|
90
|
-
value = raw[:dashboard]
|
|
91
|
-
return if value.nil?
|
|
92
|
-
|
|
93
|
-
ensure_hash!(value, "dashboard")
|
|
94
|
-
warn_unknown_keys(value, VALID_DASHBOARD_KEYS, "dashboard")
|
|
95
|
-
validate_optional_string(value[:project], "dashboard.project")
|
|
96
|
-
validate_optional_string(value[:base_url], "dashboard.base_url")
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def validate_mutation(raw)
|
|
100
|
-
value = raw[:mutation]
|
|
101
|
-
return if value.nil?
|
|
102
|
-
|
|
103
|
-
ensure_hash!(value, "mutation")
|
|
104
|
-
warn_unknown_keys(value, VALID_MUTATION_KEYS, "mutation")
|
|
105
|
-
validate_operator(value[:operators])
|
|
106
|
-
validate_mutation_limits(value)
|
|
107
|
-
validate_mutation_filters(value)
|
|
108
|
-
validate_sampling(value[:sampling])
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def validate_mutation_limits(value)
|
|
112
|
-
validate_timeout(value[:timeout])
|
|
113
|
-
validate_max_flaky_retries(value[:max_flaky_retries])
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def validate_mutation_filters(value)
|
|
117
|
-
validate_string_array(value[:ignore_patterns], "mutation.ignore_patterns")
|
|
118
|
-
validate_ignore_patterns(value[:ignore_patterns])
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
def validate_coverage_criteria(raw)
|
|
122
|
-
value = raw[:coverage_criteria]
|
|
123
|
-
return if value.nil?
|
|
124
|
-
|
|
125
|
-
ensure_hash!(value, "coverage_criteria")
|
|
126
|
-
warn_unknown_keys(value, VALID_COVERAGE_CRITERIA_KEYS, "coverage_criteria")
|
|
127
|
-
value.each do |key, flag|
|
|
128
|
-
validate_boolean(flag, "coverage_criteria.#{key}")
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def validate_thresholds(raw)
|
|
133
|
-
value = raw[:thresholds]
|
|
134
|
-
return if value.nil?
|
|
135
|
-
|
|
136
|
-
ensure_hash!(value, "thresholds")
|
|
137
|
-
warn_unknown_keys(value, VALID_THRESHOLDS_KEYS, "thresholds")
|
|
138
|
-
value.each do |key, threshold|
|
|
139
|
-
validate_threshold(threshold, "thresholds.#{key}")
|
|
140
|
-
end
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def validate_operator(value)
|
|
144
|
-
return if value.nil?
|
|
145
|
-
|
|
146
|
-
operator = value.respond_to?(:to_sym) ? value.to_sym : nil
|
|
147
|
-
return if VALID_OPERATORS.include?(operator)
|
|
148
|
-
|
|
149
|
-
configuration_error(
|
|
150
|
-
"Invalid configuration value for mutation.operators: expected one of " \
|
|
151
|
-
"#{VALID_OPERATORS.join(', ')}, got #{value.inspect}"
|
|
152
|
-
)
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
def validate_timeout(value)
|
|
156
|
-
return if value.nil?
|
|
157
|
-
return if value.is_a?(Numeric)
|
|
158
|
-
|
|
159
|
-
configuration_error("Invalid configuration value for mutation.timeout: expected Numeric, got #{value.class}")
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
def validate_threshold(value, path)
|
|
163
|
-
return if value.is_a?(Integer) && value.between?(0, 100)
|
|
164
|
-
|
|
165
|
-
configuration_error(
|
|
166
|
-
"Invalid configuration value for #{path}: expected Integer between 0 and 100, " \
|
|
167
|
-
"got #{value.inspect}"
|
|
168
|
-
)
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
def validate_boolean(value, path)
|
|
172
|
-
return if [true, false].include?(value)
|
|
173
|
-
|
|
174
|
-
configuration_error("Invalid configuration value for #{path}: expected true or false, got #{value.inspect}")
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def validate_optional_string(value, path)
|
|
178
|
-
return if value.nil?
|
|
179
|
-
return if value.is_a?(String)
|
|
180
|
-
|
|
181
|
-
configuration_error("Invalid configuration value for #{path}: expected String, got #{value.class}")
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
def validate_string_array(value, path)
|
|
185
|
-
return if value.nil?
|
|
186
|
-
return if value.is_a?(Array) && value.all?(String)
|
|
187
|
-
|
|
188
|
-
configuration_error(
|
|
189
|
-
"Invalid configuration value for #{path}: expected Array<String>, got #{describe_array_type(value)}"
|
|
190
|
-
)
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
def validate_ignore_patterns(value)
|
|
194
|
-
Array(value).each do |pattern|
|
|
195
|
-
Regexp.new(pattern)
|
|
196
|
-
rescue RegexpError => e
|
|
197
|
-
configuration_error(
|
|
198
|
-
"Invalid configuration value for mutation.ignore_patterns: " \
|
|
199
|
-
"invalid regular expression #{pattern.inspect}: #{e.message}"
|
|
200
|
-
)
|
|
201
|
-
end
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
def validate_max_flaky_retries(value)
|
|
205
|
-
return if value.nil?
|
|
206
|
-
return if value.is_a?(Integer) && value >= 0
|
|
207
|
-
|
|
208
|
-
configuration_error(
|
|
209
|
-
"Invalid configuration value for mutation.max_flaky_retries: expected Integer >= 0, got #{value.inspect}"
|
|
210
|
-
)
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
def validate_sampling(value)
|
|
214
|
-
return if value.nil?
|
|
215
|
-
|
|
216
|
-
ensure_hash!(value, "mutation.sampling")
|
|
217
|
-
warn_unknown_keys(value, VALID_SAMPLING_KEYS, "mutation.sampling")
|
|
218
|
-
validate_sampling_completeness(value)
|
|
219
|
-
validate_sampling_ratio(value[:ratio])
|
|
220
|
-
validate_sampling_strategy(value[:strategy])
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
def validate_sampling_ratio(value)
|
|
224
|
-
return if value.nil?
|
|
225
|
-
return if value.is_a?(Numeric) && value >= 0.0 && value <= 1.0
|
|
226
|
-
|
|
227
|
-
configuration_error(
|
|
228
|
-
"Invalid configuration value for mutation.sampling.ratio: " \
|
|
229
|
-
"expected Numeric between 0 and 1, got #{value.inspect}"
|
|
230
|
-
)
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
def validate_sampling_strategy(value)
|
|
234
|
-
return if value.nil?
|
|
235
|
-
|
|
236
|
-
strategy = value.respond_to?(:to_sym) ? value.to_sym : nil
|
|
237
|
-
return if strategy == :stratified
|
|
238
|
-
|
|
239
|
-
configuration_error(
|
|
240
|
-
"Invalid configuration value for mutation.sampling.strategy: expected stratified, got #{value.inspect}"
|
|
241
|
-
)
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
def validate_sampling_completeness(value)
|
|
245
|
-
return if value.key?(:ratio) && value.key?(:strategy)
|
|
246
|
-
|
|
247
|
-
configuration_error(
|
|
248
|
-
"Invalid configuration value for mutation.sampling: expected both ratio and strategy"
|
|
249
|
-
)
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
def warn_unknown_keys(raw, allowed_keys, path = nil)
|
|
253
|
-
raw.each_key do |key|
|
|
254
|
-
next if allowed_keys.include?(key)
|
|
255
|
-
|
|
256
|
-
warn "Unknown configuration key: #{key_path(path, key)}"
|
|
257
|
-
end
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
def key_path(path, key)
|
|
261
|
-
path ? "#{path}.#{key}" : key.to_s
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
def ensure_hash!(value, path)
|
|
265
|
-
return if value.is_a?(Hash)
|
|
266
|
-
|
|
267
|
-
configuration_error("Invalid configuration value for #{path}: expected Hash, got #{value.class}")
|
|
268
|
-
end
|
|
269
|
-
|
|
270
|
-
def describe_array_type(value)
|
|
271
|
-
return value.class.name unless value.is_a?(Array)
|
|
272
|
-
|
|
273
|
-
element_types = value.map { |item| item.class.name }.uniq.join(", ")
|
|
274
|
-
"Array<#{element_types}>"
|
|
275
|
-
end
|
|
276
|
-
|
|
277
|
-
def configuration_error(message)
|
|
278
|
-
raise Henitai::ConfigurationError, message
|
|
279
|
-
end
|
|
52
|
+
def self.warn(message)
|
|
53
|
+
Kernel.warn(message)
|
|
280
54
|
end
|
|
281
55
|
end
|
|
282
|
-
# rubocop:enable Metrics/ModuleLength
|
|
283
56
|
end
|
data/lib/henitai/eager_load.rb
CHANGED
|
@@ -2,10 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
require "henitai"
|
|
4
4
|
|
|
5
|
-
#
|
|
6
|
-
# (e.g. mutant) can discover subjects via ObjectSpace.
|
|
7
|
-
|
|
5
|
+
# Standalone entry point that forces every Henitai constant to load so external
|
|
6
|
+
# mutation tooling (e.g. mutant) can discover subjects via ObjectSpace.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# ruby -r henitai/eager_load -e ""
|
|
10
|
+
#
|
|
11
|
+
# A bare `Dir[].each { require }` glob loads files in filesystem order, which
|
|
12
|
+
# does not respect dependency order: a child file that reopens an autoloaded
|
|
13
|
+
# constant (e.g. `class CLI` or `class Rspec < Base`) triggers the parent
|
|
14
|
+
# autoload before the child's own constants exist, raising NameError. To avoid
|
|
15
|
+
# that, first touch every constant in the autoload table so each entry-point
|
|
16
|
+
# file loads with its `require_relative` children in the correct order; only
|
|
17
|
+
# then glob the remaining files, by which point every base class and namespace
|
|
18
|
+
# is already defined and load order no longer matters.
|
|
8
19
|
|
|
9
|
-
|
|
10
|
-
|
|
20
|
+
# Files that run as side effects on load (test hooks and coverage formatters)
|
|
21
|
+
# and must therefore be skipped by the eager loader.
|
|
22
|
+
SIDE_EFFECT_FILES = %w[
|
|
23
|
+
minitest_simplecov.rb
|
|
24
|
+
minitest_coverage_hook.rb
|
|
25
|
+
rspec_coverage_formatter.rb
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
# Recursively force every autoloaded constant under a module to load, so nested
|
|
29
|
+
# namespaces (Operators::*, Mutant::*, CLI::*, Integration::*) load their bases
|
|
30
|
+
# before the glob below touches their files.
|
|
31
|
+
force_autoloads = lambda do |mod|
|
|
32
|
+
mod.constants(false).each do |constant|
|
|
33
|
+
value = mod.const_get(constant)
|
|
34
|
+
force_autoloads.call(value) if value.is_a?(Module) && value.name&.start_with?("Henitai")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
force_autoloads.call(Henitai)
|
|
39
|
+
|
|
40
|
+
Dir[File.join(__dir__, "**/*.rb")].each do |file|
|
|
41
|
+
require file unless SIDE_EFFECT_FILES.any? { |name| file.end_with?(name) }
|
|
11
42
|
end
|
|
@@ -47,15 +47,16 @@ module Henitai
|
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
def run_parallel(mutants, integration, config, progress_reporter)
|
|
50
|
-
ProcessWorkerRunner.new(
|
|
51
|
-
|
|
52
|
-
).run(
|
|
50
|
+
runner = ProcessWorkerRunner.new(worker_count: worker_count(config))
|
|
51
|
+
results = runner.run(
|
|
53
52
|
mutants,
|
|
54
53
|
integration,
|
|
55
54
|
config,
|
|
56
55
|
progress_reporter,
|
|
57
56
|
test_file_resolver: ->(mutant) { prioritized_tests_for(mutant, integration, config) }
|
|
58
57
|
)
|
|
58
|
+
@flaky_retry_count = runner.flaky_retry_count
|
|
59
|
+
results
|
|
59
60
|
end
|
|
60
61
|
|
|
61
62
|
def process_mutant(mutant, integration, config, progress_reporter, mutex)
|
|
@@ -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
|