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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -1
  3. data/README.md +15 -3
  4. data/assets/schema/henitai.schema.json +6 -0
  5. data/lib/henitai/cli/clean_command.rb +48 -0
  6. data/lib/henitai/cli/command_support.rb +51 -0
  7. data/lib/henitai/cli/init_command.rb +64 -0
  8. data/lib/henitai/cli/operator_command.rb +95 -0
  9. data/lib/henitai/cli/options.rb +120 -0
  10. data/lib/henitai/cli/run_command.rb +103 -0
  11. data/lib/henitai/cli.rb +16 -404
  12. data/lib/henitai/configuration.rb +2 -1
  13. data/lib/henitai/configuration_validator/rules.rb +143 -0
  14. data/lib/henitai/configuration_validator/scalars.rb +123 -0
  15. data/lib/henitai/configuration_validator.rb +12 -239
  16. data/lib/henitai/eager_load.rb +36 -5
  17. data/lib/henitai/execution_engine.rb +4 -3
  18. data/lib/henitai/integration/base.rb +171 -0
  19. data/lib/henitai/integration/child_debug_support.rb +115 -0
  20. data/lib/henitai/integration/child_runtime_control.rb +50 -0
  21. data/lib/henitai/integration/coverage_suppression.rb +43 -0
  22. data/lib/henitai/integration/minitest.rb +133 -0
  23. data/lib/henitai/integration/mutant_run_support.rb +77 -0
  24. data/lib/henitai/integration/rspec_child_runner.rb +61 -0
  25. data/lib/henitai/integration/rspec_test_selection.rb +135 -0
  26. data/lib/henitai/integration/scenario_log_support.rb +116 -0
  27. data/lib/henitai/integration.rb +22 -846
  28. data/lib/henitai/mutant/activator.rb +1 -79
  29. data/lib/henitai/mutant/parameter_source.rb +98 -0
  30. data/lib/henitai/mutant.rb +1 -0
  31. data/lib/henitai/mutant_history_store/sql.rb +72 -0
  32. data/lib/henitai/mutant_history_store.rb +5 -69
  33. data/lib/henitai/per_test_coverage_collector.rb +3 -1
  34. data/lib/henitai/process_worker_runner.rb +48 -334
  35. data/lib/henitai/reporter.rb +20 -8
  36. data/lib/henitai/result.rb +17 -15
  37. data/lib/henitai/runner.rb +59 -182
  38. data/lib/henitai/slot_scheduler/draining.rb +140 -0
  39. data/lib/henitai/slot_scheduler/process_control.rb +43 -0
  40. data/lib/henitai/slot_scheduler.rb +214 -0
  41. data/lib/henitai/survivor_rerun_strategy.rb +195 -0
  42. data/lib/henitai/unparse_helper.rb +5 -2
  43. data/lib/henitai/version.rb +1 -1
  44. data/lib/henitai.rb +2 -0
  45. data/sig/configuration_validator.rbs +46 -22
  46. data/sig/henitai.rbs +158 -73
  47. 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| send(step, raw) }
48
+ Rules.ensure_hash!(raw, "configuration")
49
+ VALIDATION_STEPS.each { |step| Rules.public_send(step, raw) }
43
50
  end
44
51
 
45
- class << self
46
- private
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
@@ -2,10 +2,41 @@
2
2
 
3
3
  require "henitai"
4
4
 
5
- # Force all autoloaded constants to load so mutation testing tools
6
- # (e.g. mutant) can discover subjects via ObjectSpace.
7
- SIDE_EFFECT_FILES = %w[minitest_simplecov.rb minitest_coverage_hook.rb rspec_coverage_formatter.rb].freeze
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
- Dir[File.join(__dir__, "**/*.rb")].each do |f|
10
- require f unless SIDE_EFFECT_FILES.any? { |name| f.end_with?(name) }
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
- worker_count: worker_count(config)
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