henitai 0.1.1 → 0.1.3

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +91 -1
  3. data/README.md +3 -1
  4. data/assets/schema/henitai.schema.json +0 -4
  5. data/lib/henitai/arid_node_filter.rb +3 -0
  6. data/lib/henitai/available_cpu_count.rb +79 -0
  7. data/lib/henitai/cli.rb +7 -4
  8. data/lib/henitai/configuration.rb +3 -5
  9. data/lib/henitai/configuration_validator.rb +1 -11
  10. data/lib/henitai/coverage_bootstrapper.rb +112 -8
  11. data/lib/henitai/coverage_formatter.rb +4 -4
  12. data/lib/henitai/coverage_report_reader.rb +67 -0
  13. data/lib/henitai/eager_load.rb +11 -0
  14. data/lib/henitai/equivalence_detector.rb +60 -1
  15. data/lib/henitai/execution_engine.rb +34 -22
  16. data/lib/henitai/integration/rspec_process_runner.rb +58 -0
  17. data/lib/henitai/integration.rb +222 -100
  18. data/lib/henitai/minitest_simplecov.rb +3 -0
  19. data/lib/henitai/mutant/activator.rb +78 -42
  20. data/lib/henitai/mutant.rb +3 -1
  21. data/lib/henitai/mutant_generator.rb +25 -48
  22. data/lib/henitai/operator.rb +6 -1
  23. data/lib/henitai/operators/assignment_expression.rb +7 -23
  24. data/lib/henitai/operators/conditional_expression.rb +1 -7
  25. data/lib/henitai/operators/method_chain_unwrap.rb +41 -0
  26. data/lib/henitai/operators/regex_mutator.rb +89 -0
  27. data/lib/henitai/operators/unary_operator.rb +36 -0
  28. data/lib/henitai/operators/update_operator.rb +70 -0
  29. data/lib/henitai/operators.rb +4 -0
  30. data/lib/henitai/parallel_execution_runner.rb +135 -0
  31. data/lib/henitai/per_test_coverage_selector.rb +60 -0
  32. data/lib/henitai/result.rb +16 -4
  33. data/lib/henitai/rspec_coverage_formatter.rb +10 -0
  34. data/lib/henitai/runner.rb +75 -11
  35. data/lib/henitai/source_parser.rb +12 -1
  36. data/lib/henitai/static_filter.rb +53 -38
  37. data/lib/henitai/version.rb +1 -1
  38. data/lib/henitai.rb +3 -0
  39. data/sig/henitai.rbs +65 -11
  40. metadata +18 -4
@@ -10,7 +10,7 @@ module Henitai
10
10
  # obvious enough to be useful.
11
11
  class EquivalenceDetector
12
12
  def analyze(mutant)
13
- return mutant unless equivalent_arithmetic_mutation?(mutant)
13
+ return mutant unless equivalent_mutation?(mutant)
14
14
 
15
15
  mutant.status = :equivalent
16
16
  mutant
@@ -18,6 +18,10 @@ module Henitai
18
18
 
19
19
  private
20
20
 
21
+ def equivalent_mutation?(mutant)
22
+ equivalent_arithmetic_mutation?(mutant) || equivalent_logical_mutation?(mutant)
23
+ end
24
+
21
25
  def equivalent_arithmetic_mutation?(mutant)
22
26
  original = mutant.original_node
23
27
  mutated = mutant.mutated_node
@@ -50,6 +54,61 @@ module Henitai
50
54
  one_operand?(mutated)
51
55
  end
52
56
 
57
+ def equivalent_logical_mutation?(mutant)
58
+ original = mutant.original_node
59
+ mutated = mutant.mutated_node
60
+ return false unless logical_node?(original)
61
+
62
+ logical_identity_equivalent?(original, mutated)
63
+ end
64
+
65
+ def logical_node?(node)
66
+ node.is_a?(Parser::AST::Node) && %i[and or].include?(node.type)
67
+ end
68
+
69
+ def logical_identity_equivalent?(original, mutated)
70
+ case original.type
71
+ when :or
72
+ false_identity_equivalent?(original, mutated)
73
+ when :and
74
+ true_identity_equivalent?(original, mutated)
75
+ else
76
+ false
77
+ end
78
+ end
79
+
80
+ def false_identity_equivalent?(original, mutated)
81
+ return true if false_operand?(original.children[0]) && same_node?(mutated, original.children[1])
82
+ return true if false_operand?(original.children[1]) && same_node?(mutated, original.children[0])
83
+
84
+ false
85
+ end
86
+
87
+ def true_identity_equivalent?(original, mutated)
88
+ return true if true_operand?(original.children[0]) && same_node?(mutated, original.children[1])
89
+ return true if true_operand?(original.children[1]) && same_node?(mutated, original.children[0])
90
+
91
+ false
92
+ end
93
+
94
+ def false_operand?(node)
95
+ # Parser uses :true / :false node types, so the AST symbols are intentional.
96
+ # rubocop:disable Lint/BooleanSymbol
97
+ boolean_literal?(node, :false)
98
+ # rubocop:enable Lint/BooleanSymbol
99
+ end
100
+
101
+ def true_operand?(node)
102
+ # Parser uses :true / :false node types, so the AST symbols are intentional.
103
+ # rubocop:disable Lint/BooleanSymbol
104
+ boolean_literal?(node, :true)
105
+ # rubocop:enable Lint/BooleanSymbol
106
+ end
107
+
108
+ def boolean_literal?(node, type)
109
+ node.is_a?(Parser::AST::Node) && node.type == type && node.children.empty?
110
+ end
111
+
53
112
  def additive_operator?(operator)
54
113
  %i[+ -].include?(operator)
55
114
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "etc"
3
+ require_relative "parallel_execution_runner"
4
4
 
5
5
  module Henitai
6
6
  # Runs pending mutants through the selected integration.
@@ -12,7 +12,7 @@ module Henitai
12
12
  pending_mutants = Array(mutants).select(&:pending?)
13
13
  mutex = Mutex.new
14
14
  if parallel_execution?(config, pending_mutants)
15
- run_parallel(pending_mutants, integration, config, progress_reporter, mutex)
15
+ run_parallel(pending_mutants, integration, config, progress_reporter)
16
16
  else
17
17
  run_linear(pending_mutants, integration, config, progress_reporter, mutex)
18
18
  end
@@ -31,7 +31,12 @@ module Henitai
31
31
 
32
32
  def worker_count(config)
33
33
  configured_jobs = config.respond_to?(:jobs) ? config.jobs : nil
34
- configured_jobs || Etc.nprocessors
34
+ return configured_jobs if configured_jobs
35
+
36
+ # The fallback stays conservative for now; the execution policy still
37
+ # defaults to a single worker even though AvailableCpuCount exists as a
38
+ # future policy hook.
39
+ 1
35
40
  end
36
41
 
37
42
  def run_linear(mutants, integration, config, progress_reporter, mutex)
@@ -40,24 +45,29 @@ module Henitai
40
45
  end
41
46
  end
42
47
 
43
- def run_parallel(mutants, integration, config, progress_reporter, mutex)
44
- queue = Queue.new
45
- mutants.each { |mutant| queue << mutant }
48
+ def run_parallel(mutants, integration, config, progress_reporter)
49
+ ParallelExecutionRunner.new(
50
+ worker_count: worker_count(config)
51
+ ).run(
52
+ mutants,
53
+ integration,
54
+ config,
55
+ progress_reporter,
56
+ stdin_pipe: pipe_stdin?,
57
+ process_mutant: method(:process_mutant)
58
+ )
59
+ end
46
60
 
47
- Array.new(worker_count(config)) do
48
- Thread.new do
49
- loop do
50
- mutant = queue.pop(true)
51
- process_mutant(mutant, integration, config, progress_reporter, mutex)
52
- rescue ThreadError
53
- break
54
- end
55
- end
56
- end.each(&:join)
61
+ def pipe_stdin?
62
+ $stdin.stat.pipe?
63
+ rescue Errno::EBADF
64
+ false
57
65
  end
58
66
 
59
67
  def process_mutant(mutant, integration, config, progress_reporter, mutex)
60
68
  test_files = prioritized_tests_for(mutant, integration, config)
69
+ mutant.covered_by = test_files if mutant.respond_to?(:covered_by=)
70
+ mutant.tests_completed = test_files.size if mutant.respond_to?(:tests_completed=)
61
71
  scenario_result = run_with_flaky_retry(mutant, integration, config, test_files, mutex)
62
72
  mutant.status = scenario_status(scenario_result)
63
73
 
@@ -69,16 +79,18 @@ module Henitai
69
79
  end
70
80
 
71
81
  def prioritized_tests_for(mutant, integration, config)
72
- test_prioritizer.sort(
73
- integration.select_tests(mutant.subject),
82
+ tests = integration.select_tests(mutant.subject)
83
+ tests = per_test_coverage_selector.filter(
84
+ tests,
74
85
  mutant,
75
- test_history(config)
86
+ reports_dir: config.reports_dir
76
87
  )
88
+ test_prioritizer.sort(tests, mutant, test_history(config))
77
89
  end
78
90
 
79
- def test_prioritizer
80
- @test_prioritizer ||= TestPrioritizer.new
81
- end
91
+ def test_prioritizer = @test_prioritizer ||= TestPrioritizer.new
92
+
93
+ def per_test_coverage_selector = @per_test_coverage_selector ||= PerTestCoverageSelector.new
82
94
 
83
95
  def test_history(config)
84
96
  return {} unless config.respond_to?(:history)
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ module Integration
5
+ # Runs RSpec child and suite processes on behalf of the integration.
6
+ class RspecProcessRunner
7
+ def run_mutant(integration, mutant:, test_files:, timeout:)
8
+ log_paths = integration.scenario_log_paths(mutant_log_name(mutant))
9
+ pid = fork_mutant_process(integration, mutant, test_files, log_paths)
10
+ wait_result = integration.wait_with_timeout(pid, timeout)
11
+ integration.build_result(wait_result, log_paths)
12
+ ensure
13
+ finalize_mutant_run(integration, pid, wait_result)
14
+ end
15
+
16
+ def run_suite(integration, test_files, timeout:)
17
+ log_paths = integration.scenario_log_paths("baseline")
18
+ wait_result = nil
19
+ FileUtils.mkdir_p(File.dirname(log_paths[:stdout_path]))
20
+ pid = integration.spawn_suite_process(test_files, log_paths)
21
+ wait_result = integration.wait_with_timeout(pid, timeout)
22
+ integration.build_result(wait_result, log_paths)
23
+ ensure
24
+ if pid
25
+ integration.cleanup_process_group(pid) unless wait_result == :timeout
26
+ integration.reap_child(pid) if wait_result.nil?
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def fork_mutant_process(integration, mutant, test_files, log_paths)
33
+ Process.fork do
34
+ Process.setpgid(0, 0)
35
+ ENV["HENITAI_MUTANT_ID"] = mutant.id
36
+ Process.exit(
37
+ integration.run_in_child(
38
+ mutant: mutant,
39
+ test_files: test_files,
40
+ log_paths: log_paths
41
+ )
42
+ )
43
+ end
44
+ end
45
+
46
+ def finalize_mutant_run(integration, pid, wait_result)
47
+ return unless pid
48
+
49
+ integration.cleanup_process_group(pid) unless wait_result == :timeout
50
+ integration.reap_child(pid) if wait_result.nil?
51
+ end
52
+
53
+ def mutant_log_name(mutant)
54
+ "mutant-#{mutant.id}"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "minitest"
5
+ require_relative "integration/rspec_process_runner"
5
6
 
6
7
  module Henitai
7
8
  # Namespace for test-framework integrations.
@@ -56,8 +57,8 @@ module Henitai
56
57
 
57
58
  def build_child_output_files(log_paths)
58
59
  {
59
- original_stdout: $stdout.dup,
60
- original_stderr: $stderr.dup,
60
+ original_stdout: stdout_stream.dup,
61
+ original_stderr: stderr_stream.dup,
61
62
  stdout_file: File.new(log_paths[:stdout_path], "w"),
62
63
  stderr_file: File.new(log_paths[:stderr_path], "w")
63
64
  }
@@ -69,13 +70,17 @@ module Henitai
69
70
  end
70
71
 
71
72
  def redirect_child_output(output_files)
72
- $stdout.reopen(output_files[:stdout_file])
73
- $stderr.reopen(output_files[:stderr_file])
73
+ reopen_child_output_stream(stdout_stream, output_files[:stdout_file])
74
+ reopen_child_output_stream(stderr_stream, output_files[:stderr_file])
75
+ $stdout = stdout_stream
76
+ $stderr = stderr_stream
74
77
  end
75
78
 
76
79
  def restore_child_output(output_files)
77
- reopen_child_output_stream($stdout, output_files[:original_stdout])
78
- reopen_child_output_stream($stderr, output_files[:original_stderr])
80
+ reopen_child_output_stream(stdout_stream, output_files[:original_stdout])
81
+ reopen_child_output_stream(stderr_stream, output_files[:original_stderr])
82
+ $stdout = output_files[:original_stdout]
83
+ $stderr = output_files[:original_stderr]
79
84
  end
80
85
 
81
86
  def reopen_child_output_stream(stream, original_stream)
@@ -94,6 +99,14 @@ module Henitai
94
99
  reports_dir = ENV.fetch("HENITAI_REPORTS_DIR", "reports")
95
100
  File.join(reports_dir, "mutation-coverage", mutant_id.to_s)
96
101
  end
102
+
103
+ def stdout_stream
104
+ @stdout_stream ||= IO.for_fd(1)
105
+ end
106
+
107
+ def stderr_stream
108
+ @stderr_stream ||= IO.for_fd(2)
109
+ end
97
110
  end
98
111
 
99
112
  # Integration adapter for RSpec.
@@ -105,7 +118,12 @@ module Henitai
105
118
  def self.for(name)
106
119
  const_get(name.capitalize)
107
120
  rescue NameError
108
- raise ArgumentError, "Unknown integration: #{name}. Available: rspec"
121
+ available = constants.filter_map do |constant_name|
122
+ integration = const_get(constant_name)
123
+ constant_name.to_s.downcase if integration.is_a?(Class) && integration < Base
124
+ end.sort.join(", ")
125
+
126
+ raise ArgumentError, "Unknown integration: #{name}. Available: #{available}"
109
127
  end
110
128
 
111
129
  # Base class for all integrations.
@@ -130,6 +148,100 @@ module Henitai
130
148
  def run_mutant(mutant:, test_files:, timeout:)
131
149
  raise NotImplementedError
132
150
  end
151
+
152
+ def per_test_coverage_supported?
153
+ false
154
+ end
155
+
156
+ def wait_with_timeout(pid, timeout)
157
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
158
+
159
+ loop do
160
+ wait_result = Process.wait(pid, Process::WNOHANG)
161
+ return Process.last_status if wait_result
162
+
163
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
164
+ final_wait_result = Process.wait(pid, Process::WNOHANG)
165
+ return Process.last_status if final_wait_result
166
+
167
+ return handle_timeout(pid)
168
+ end
169
+
170
+ pause(0.01)
171
+ end
172
+ end
173
+
174
+ def reap_child(pid)
175
+ Process.wait(pid)
176
+ rescue Errno::ECHILD, Errno::ESRCH
177
+ nil
178
+ end
179
+
180
+ def cleanup_process_group(pid)
181
+ Process.kill(:SIGTERM, -pid)
182
+ pause(2.0)
183
+ Process.kill(:SIGKILL, -pid)
184
+ rescue Errno::EPERM
185
+ cleanup_child_process(pid)
186
+ rescue Errno::ESRCH
187
+ nil
188
+ end
189
+
190
+ private
191
+
192
+ def pause(seconds)
193
+ sleep(seconds)
194
+ end
195
+
196
+ def handle_timeout(pid)
197
+ begin
198
+ cleanup_process_group(pid)
199
+ ensure
200
+ reap_child(pid)
201
+ end
202
+ :timeout
203
+ end
204
+
205
+ def cleanup_child_process(pid)
206
+ Process.kill(:SIGTERM, pid)
207
+ pause(2.0)
208
+ Process.kill(:SIGKILL, pid)
209
+ rescue Errno::EPERM, Errno::ESRCH
210
+ nil
211
+ end
212
+
213
+ def rspec_options
214
+ []
215
+ end
216
+
217
+ def subprocess_env
218
+ { "PARALLEL_WORKERS" => "1" }
219
+ end
220
+
221
+ def scenario_log_support
222
+ @scenario_log_support ||= ScenarioLogSupport.new
223
+ end
224
+
225
+ def with_subprocess_env
226
+ original_env = {} # : Hash[String, String?]
227
+ subprocess_env.each do |key, value|
228
+ original_env[key] = ENV.fetch(key, nil)
229
+ ENV[key] = value
230
+ end
231
+ yield
232
+ ensure
233
+ restore_subprocess_env(original_env)
234
+ end
235
+
236
+ def restore_subprocess_env(original_env)
237
+ original_env.each do |key, value|
238
+ if value.nil?
239
+ ENV.delete(key)
240
+ else
241
+ ENV[key] = value
242
+ end
243
+ end
244
+ end
133
245
  end
134
246
 
135
247
  # RSpec integration adapter.
@@ -162,64 +274,25 @@ module Henitai
162
274
  end
163
275
 
164
276
  def run_mutant(mutant:, test_files:, timeout:)
165
- log_paths = scenario_log_paths("mutant-#{mutant.id}")
166
- pid = Process.fork do
167
- ENV["HENITAI_MUTANT_ID"] = mutant.id
168
- Process.exit(run_in_child(mutant:, test_files:, log_paths:))
169
- end
170
-
171
- build_result(wait_with_timeout(pid, timeout), log_paths)
277
+ RspecProcessRunner.new.run_mutant(self, mutant:, test_files:, timeout:)
172
278
  end
173
279
 
174
- def run_suite(test_files, timeout: DEFAULT_SUITE_TIMEOUT)
175
- log_paths = scenario_log_paths("baseline")
176
- FileUtils.mkdir_p(File.dirname(log_paths[:stdout_path]))
177
- pid = File.open(log_paths[:stdout_path], "w") do |stdout_file|
178
- File.open(log_paths[:stderr_path], "w") do |stderr_file|
179
- Process.spawn(*suite_command(test_files), out: stdout_file, err: stderr_file)
180
- end
181
- end
182
- build_result(wait_with_timeout(pid, timeout), log_paths)
280
+ def per_test_coverage_supported?
281
+ true
183
282
  end
184
283
 
185
- private
186
-
187
- def run_in_child(mutant:, test_files:, log_paths:)
188
- scenario_log_support.with_coverage_dir(mutant.id) do
189
- scenario_log_support.capture_child_output(log_paths) do
190
- return 2 if Mutant::Activator.activate!(mutant) == :compile_error
191
-
192
- run_tests(test_files)
193
- end
194
- end
284
+ def run_suite(test_files, timeout: DEFAULT_SUITE_TIMEOUT)
285
+ RspecProcessRunner.new.run_suite(self, test_files, timeout:)
195
286
  end
196
287
 
197
288
  def suite_command(test_files)
198
- ["bundle", "exec", "rspec", *test_files]
199
- end
200
-
201
- def wait_with_timeout(pid, timeout)
202
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
203
-
204
- loop do
205
- return Process.last_status if Process.wait(pid, Process::WNOHANG)
206
- return handle_timeout(pid) if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
207
-
208
- pause(0.01)
209
- end
210
- end
211
-
212
- def handle_timeout(pid)
213
- begin
214
- Process.kill(:SIGTERM, pid)
215
- pause(2.0)
216
- Process.kill(:SIGKILL, pid)
217
- rescue Errno::ESRCH
218
- # The child may exit after SIGTERM but before SIGKILL.
219
- ensure
220
- reap_child(pid)
221
- end
222
- :timeout
289
+ [
290
+ "bundle", "exec", "ruby",
291
+ "-r", "henitai/rspec_coverage_formatter",
292
+ "-S", "rspec", *test_files,
293
+ "--format", "progress",
294
+ "--format", "Henitai::CoverageFormatter"
295
+ ]
223
296
  end
224
297
 
225
298
  def run_tests(test_files)
@@ -230,36 +303,6 @@ module Henitai
230
303
  status == true ? 0 : 1
231
304
  end
232
305
 
233
- def rspec_options
234
- ["--require", "henitai/coverage_formatter"]
235
- end
236
-
237
- def pause(seconds)
238
- sleep(seconds)
239
- end
240
-
241
- def scenario_log_support
242
- @scenario_log_support ||= ScenarioLogSupport.new
243
- end
244
-
245
- def read_log_file(path)
246
- return "" unless File.exist?(path)
247
-
248
- File.read(path)
249
- end
250
-
251
- def write_combined_log(path, stdout, stderr)
252
- FileUtils.mkdir_p(File.dirname(path))
253
- File.write(path, combined_log(stdout, stderr))
254
- end
255
-
256
- def combined_log(stdout, stderr)
257
- [
258
- (stdout.empty? ? nil : "stdout:\n#{stdout}"),
259
- (stderr.empty? ? nil : "stderr:\n#{stderr}")
260
- ].compact.join("\n")
261
- end
262
-
263
306
  def scenario_log_paths(name)
264
307
  reports_dir = ENV.fetch("HENITAI_REPORTS_DIR", "reports")
265
308
  log_dir = File.join(reports_dir, "mutation-logs")
@@ -378,10 +421,70 @@ module Henitai
378
421
  ].uniq
379
422
  end
380
423
 
381
- def reap_child(pid)
382
- Process.wait(pid)
383
- rescue Errno::ECHILD, Errno::ESRCH
384
- nil
424
+ def spawn_suite_process(test_files, log_paths)
425
+ File.open(log_paths[:stdout_path], "w") do |stdout_file|
426
+ File.open(log_paths[:stderr_path], "w") do |stderr_file|
427
+ Process.spawn(
428
+ subprocess_env,
429
+ *suite_command(test_files),
430
+ out: stdout_file,
431
+ err: stderr_file,
432
+ pgroup: true
433
+ )
434
+ end
435
+ end
436
+ end
437
+
438
+ def run_in_child(mutant:, test_files:, log_paths:)
439
+ Thread.report_on_exception = false
440
+ with_subprocess_env do
441
+ scenario_log_support.with_coverage_dir(mutant.id) do
442
+ scenario_log_support.capture_child_output(log_paths) do
443
+ return 2 if Mutant::Activator.activate!(mutant) == :compile_error
444
+
445
+ run_tests(test_files)
446
+ end
447
+ end
448
+ end
449
+ end
450
+
451
+ def read_log_file(path)
452
+ return "" unless File.exist?(path)
453
+
454
+ File.read(path)
455
+ end
456
+
457
+ def write_combined_log(path, stdout, stderr)
458
+ FileUtils.mkdir_p(File.dirname(path))
459
+ File.write(path, combined_log(stdout, stderr))
460
+ end
461
+
462
+ def combined_log(stdout, stderr)
463
+ [
464
+ (stdout.empty? ? nil : "stdout:\n#{stdout}"),
465
+ (stderr.empty? ? nil : "stderr:\n#{stderr}")
466
+ ].compact.join("\n")
467
+ end
468
+ end
469
+
470
+ # Stores the child-process log helpers shared by the integration specs.
471
+ class ScenarioLogSupport
472
+ def read_log_file(path)
473
+ return "" unless File.exist?(path)
474
+
475
+ File.read(path)
476
+ end
477
+
478
+ def write_combined_log(path, stdout, stderr)
479
+ FileUtils.mkdir_p(File.dirname(path))
480
+ File.write(path, combined_log(stdout, stderr))
481
+ end
482
+
483
+ def combined_log(stdout, stderr)
484
+ [
485
+ (stdout.empty? ? nil : "stdout:\n#{stdout}"),
486
+ (stderr.empty? ? nil : "stderr:\n#{stderr}")
487
+ ].compact.join("\n")
385
488
  end
386
489
  end
387
490
 
@@ -404,13 +507,11 @@ module Henitai
404
507
 
405
508
  def run_suite(test_files, timeout: DEFAULT_SUITE_TIMEOUT)
406
509
  log_paths = scenario_log_paths("baseline")
407
- FileUtils.mkdir_p(File.dirname(log_paths[:stdout_path]))
408
- pid = File.open(log_paths[:stdout_path], "w") do |stdout_file|
409
- File.open(log_paths[:stderr_path], "w") do |stderr_file|
410
- Process.spawn(subprocess_env, *suite_command(test_files), out: stdout_file, err: stderr_file)
411
- end
412
- end
413
- build_result(wait_with_timeout(pid, timeout), log_paths)
510
+ pid = spawn_suite_process(test_files, log_paths)
511
+ wait_result = wait_with_timeout(pid, timeout)
512
+ build_result(wait_result, log_paths)
513
+ ensure
514
+ cleanup_suite_process(pid, wait_result)
414
515
  end
415
516
 
416
517
  private
@@ -443,12 +544,33 @@ module Henitai
443
544
  end
444
545
 
445
546
  def subprocess_env
446
- env = {} # : Hash[String, String]
547
+ env = super
447
548
  env["RAILS_ENV"] = "test" unless ENV["RAILS_ENV"] == "test"
448
549
  env["PARALLEL_WORKERS"] = "1"
449
550
  env
450
551
  end
451
552
 
553
+ def spawn_suite_process(test_files, log_paths)
554
+ FileUtils.mkdir_p(File.dirname(log_paths[:stdout_path]))
555
+ File.open(log_paths[:stdout_path], "w") do |stdout_file|
556
+ File.open(log_paths[:stderr_path], "w") do |stderr_file|
557
+ Process.spawn(
558
+ subprocess_env,
559
+ *suite_command(test_files),
560
+ out: stdout_file,
561
+ err: stderr_file
562
+ )
563
+ end
564
+ end
565
+ end
566
+
567
+ def cleanup_suite_process(pid, wait_result)
568
+ return unless pid
569
+
570
+ cleanup_child_process(pid)
571
+ reap_child(pid) if wait_result.nil?
572
+ end
573
+
452
574
  def spec_files
453
575
  (Dir.glob("test/**/*_test.rb") + Dir.glob("test/**/*_spec.rb"))
454
576
  .reject { |f| f.start_with?("test/system/") }
@@ -6,6 +6,9 @@
6
6
  # Must be required before any application code is loaded so that Coverage
7
7
  # tracking is active from the first line.
8
8
 
9
+ require "coverage"
10
+ Coverage.start(lines: true, branches: true, methods: true)
11
+
9
12
  require "simplecov"
10
13
 
11
14
  SimpleCov.coverage_dir(ENV.fetch("HENITAI_COVERAGE_DIR", "coverage"))