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
@@ -4,6 +4,13 @@ require "fileutils"
4
4
  require "stringio"
5
5
  require_relative "process_wakeup"
6
6
  require_relative "integration/rspec_process_runner"
7
+ require_relative "integration/scenario_log_support"
8
+ require_relative "integration/coverage_suppression"
9
+ require_relative "integration/child_debug_support"
10
+ require_relative "integration/base"
11
+ require_relative "integration/mutant_run_support"
12
+ require_relative "integration/rspec_child_runner"
13
+ require_relative "integration/rspec_test_selection"
7
14
 
8
15
  module Henitai
9
16
  # Namespace for test-framework integrations.
@@ -20,294 +27,6 @@ module Henitai
20
27
  # Built-in integrations:
21
28
  # rspec — RSpec 3.x
22
29
  module Integration
23
- # Shared helpers for capturing stdout/stderr from child test processes.
24
- class ScenarioLogSupport
25
- def capture_child_output(log_paths)
26
- output_files = open_child_output(log_paths)
27
- yield
28
- ensure
29
- close_child_output(output_files)
30
- end
31
-
32
- def with_coverage_dir(mutant_id)
33
- original_coverage_dir = ENV.fetch("HENITAI_COVERAGE_DIR", nil)
34
- ENV["HENITAI_COVERAGE_DIR"] = mutation_coverage_dir(mutant_id)
35
- yield
36
- ensure
37
- if original_coverage_dir.nil?
38
- ENV.delete("HENITAI_COVERAGE_DIR")
39
- else
40
- ENV["HENITAI_COVERAGE_DIR"] = original_coverage_dir
41
- end
42
- end
43
-
44
- def open_child_output(log_paths)
45
- FileUtils.mkdir_p(File.dirname(log_paths[:log_path]))
46
- output_files = build_child_output_files(log_paths)
47
- sync_child_output_files(output_files)
48
- redirect_child_output(output_files)
49
- output_files
50
- end
51
-
52
- def close_child_output(output_files)
53
- return unless output_files
54
-
55
- restore_child_output(output_files)
56
- close_child_output_files(output_files)
57
- end
58
-
59
- def build_child_output_files(log_paths)
60
- {
61
- original_stdout: stdout_stream.dup,
62
- original_stderr: stderr_stream.dup,
63
- stdout_file: File.new(log_paths[:stdout_path], "w"),
64
- stderr_file: File.new(log_paths[:stderr_path], "w")
65
- }
66
- end
67
-
68
- def sync_child_output_files(output_files)
69
- output_files[:stdout_file].sync = true
70
- output_files[:stderr_file].sync = true
71
- end
72
-
73
- def redirect_child_output(output_files)
74
- reopen_child_output_stream(stdout_stream, output_files[:stdout_file])
75
- reopen_child_output_stream(stderr_stream, output_files[:stderr_file])
76
- $stdout = stdout_stream
77
- $stderr = stderr_stream
78
- end
79
-
80
- def restore_child_output(output_files)
81
- reopen_child_output_stream(stdout_stream, output_files[:original_stdout])
82
- reopen_child_output_stream(stderr_stream, output_files[:original_stderr])
83
- $stdout = stdout_stream
84
- $stderr = stderr_stream
85
- end
86
-
87
- def reopen_child_output_stream(stream, original_stream)
88
- stream.reopen(original_stream) if original_stream
89
- end
90
-
91
- def close_child_output_files(output_files)
92
- %i[stdout_file stderr_file original_stdout original_stderr].each do |key|
93
- output_files[key]&.close
94
- end
95
- end
96
-
97
- private
98
-
99
- def mutation_coverage_dir(mutant_id)
100
- reports_dir = ENV.fetch("HENITAI_REPORTS_DIR", "reports")
101
- File.join(reports_dir, "mutation-coverage", mutant_id.to_s)
102
- end
103
-
104
- def stdout_stream
105
- @stdout_stream ||= IO.for_fd(1)
106
- end
107
-
108
- def stderr_stream
109
- @stderr_stream ||= IO.for_fd(2)
110
- end
111
- end
112
-
113
- # Shared debug helpers for child-run diagnostics.
114
- # Debug helpers are intentionally grouped here so the child-run diagnostics
115
- # stay isolated from the main integration flow.
116
- # rubocop:disable Metrics/ModuleLength
117
- module ChildDebugSupport
118
- private
119
-
120
- def run_rspec_runner(test_files)
121
- debug_child_puts("[henitai-debug-child] build_rspec_runner_start")
122
- runner = build_rspec_runner
123
- debug_child_puts("[henitai-debug-child] build_rspec_runner_return")
124
- debug_child_puts("[henitai-debug-child] configure_rspec_runner_start")
125
- configure_rspec_runner(runner)
126
- debug_child_puts("[henitai-debug-child] configure_rspec_runner_return")
127
- load_rspec_spec_files(test_files)
128
- run_rspec_specs(runner)
129
- rescue SystemExit => e
130
- debug_child_puts("[henitai-debug-child] runner_run_system_exit status=#{e.status.inspect}")
131
- raise
132
- ensure
133
- debug_child_puts("[henitai-debug-child] runner_run_ensure")
134
- end
135
-
136
- def build_rspec_runner
137
- # @type var empty_args: Array[String]
138
- empty_args = []
139
- configuration_options = ::RSpec::Core.const_get(:ConfigurationOptions).new(empty_args)
140
- ::RSpec::Core::Runner.__send__(:new, configuration_options)
141
- end
142
-
143
- def configure_rspec_runner(runner)
144
- debug_child_puts("[henitai-debug-child] trap_interrupt_start")
145
- ::RSpec::Core::Runner.__send__(:trap_interrupt)
146
- debug_child_puts("[henitai-debug-child] trap_interrupt_return")
147
- debug_child_puts("[henitai-debug-child] runner_configure_start")
148
- runner.send(:configure, $stderr, $stdout)
149
- debug_child_puts("[henitai-debug-child] runner_configure_return")
150
- end
151
-
152
- def load_rspec_spec_files(test_files)
153
- debug_child_puts("[henitai-debug-child] load_spec_files_start")
154
- ::RSpec.__send__(:configuration).files_to_run = test_files.map do |file|
155
- File.expand_path(file)
156
- end
157
- ::RSpec.__send__(:configuration).load_spec_files
158
- debug_child_example_count("after_load")
159
- debug_child_puts("[henitai-debug-child] load_spec_files_return")
160
- end
161
-
162
- def run_rspec_specs(runner)
163
- debug_child_puts("[henitai-debug-child] run_specs_start")
164
- result = runner.send(:run_specs, ::RSpec.__send__(:world).ordered_example_groups)
165
- debug_child_puts("[henitai-debug-child] run_specs_return result=#{result.inspect}")
166
- result
167
- end
168
-
169
- def debug_child? = ENV["HENITAI_DEBUG_CHILD"] == "1"
170
-
171
- def debug_child_puts(message)
172
- $stdout.puts(message)
173
- $stdout.flush
174
- end
175
-
176
- def debug_child_rspec_trace(test_files:, rspec_options:, rspec_argv:)
177
- return unless debug_child?
178
-
179
- files_exist = test_files.map { |f| [f, File.exist?(f)] }.inspect
180
- loaded_features = loaded_feature_map(test_files).inspect # steep:ignore Ruby::NoMethod
181
-
182
- debug_child_puts(
183
- "[henitai-debug-child] cwd=#{Dir.pwd}\n" \
184
- "[henitai-debug-child] files_exist=#{files_exist}\n" \
185
- "[henitai-debug-child] loaded_features_check=#{loaded_features}\n" \
186
- "[henitai-debug-child] test_files=#{test_files.inspect}\n" \
187
- "[henitai-debug-child] rspec_options=#{rspec_options.inspect}\n" \
188
- "[henitai-debug-child] rspec_argv=#{rspec_argv.inspect}"
189
- )
190
- end
191
-
192
- def debug_child_rspec_exit(status)
193
- return unless debug_child?
194
-
195
- debug_child_puts("[henitai-debug-child] RSpec result=#{status.inspect}")
196
- end
197
-
198
- def suppress_simplecov!
199
- CoverageRuntimeSuppressors.suppress_simplecov!
200
- end
201
-
202
- def suppress_coverage!
203
- CoverageRuntimeSuppressors.suppress_coverage!
204
- end
205
-
206
- def debug_child_example_count(stage) # steep:ignore Ruby::UndeclaredMethodDefinition
207
- return unless debug_child?
208
-
209
- count = rspec_world_example_count
210
- debug_child_puts(
211
- "[henitai-debug-child] rspec_world_example_count_#{stage}=#{count.inspect}"
212
- )
213
- end
214
-
215
- def debug_child_activation_start(mutant_id)
216
- return unless debug_child?
217
-
218
- debug_child_puts("[henitai-debug-child] activate_start mutant=#{mutant_id}")
219
- end
220
-
221
- def debug_child_activation_end(activation_result, test_files:)
222
- return unless debug_child?
223
-
224
- debug_child_puts(
225
- "[henitai-debug-child] activate_end result=#{activation_result.inspect}\n" \
226
- "[henitai-debug-child] run_tests_start test_files=#{test_files.inspect}"
227
- )
228
- end
229
-
230
- def debug_child_mutant_meta(mutant)
231
- stable_id = mutant.respond_to?(:stable_id) ? mutant.stable_id : nil
232
- operator = mutant.respond_to?(:operator) ? mutant.operator : nil
233
- has_subject_expression =
234
- mutant.respond_to?(:subject) && mutant.subject.respond_to?(:expression)
235
- subject_expression = has_subject_expression ? mutant.subject.expression : nil
236
- location = mutant.respond_to?(:location) ? mutant.location.inspect : nil
237
-
238
- debug_child_puts(
239
- "[henitai-debug-child] mutant_meta stableId=#{stable_id}\n" \
240
- "[henitai-debug-child] mutant_meta operator=#{operator}\n" \
241
- "[henitai-debug-child] mutant_meta subject=#{subject_expression}\n" \
242
- "[henitai-debug-child] mutant_meta location=#{location}\n"
243
- )
244
- end
245
-
246
- def debug_child_activation_check
247
- location = begin
248
- Henitai::Runner.instance_method(:resolve_subjects).source_location&.join(":")
249
- rescue StandardError
250
- nil
251
- end
252
-
253
- debug_child_puts(
254
- "[henitai-debug-child] activation_check resolve_subjects_location=#{location}\n"
255
- )
256
- end
257
-
258
- def loaded_feature_map(test_files) = test_files.map { |file| [file, loaded_feature?(file)] } # steep:ignore Ruby::UndeclaredMethodDefinition
259
-
260
- def loaded_feature?(file) # steep:ignore Ruby::UndeclaredMethodDefinition
261
- expanded = File.expand_path(file)
262
- candidates = [expanded, "#{expanded}.rb", file, "#{file}.rb"].uniq
263
- $LOADED_FEATURES.any? do |feature|
264
- normalized = begin
265
- File.expand_path(feature)
266
- rescue StandardError
267
- feature
268
- end
269
- candidates.include?(feature) || candidates.include?(normalized)
270
- end
271
- end
272
-
273
- def rspec_world_example_count # steep:ignore Ruby::UndeclaredMethodDefinition
274
- world = ::RSpec.__send__(:world)
275
- world.example_count
276
- rescue StandardError
277
- nil
278
- end
279
-
280
- def debug_child_timeout_dump(pid)
281
- return unless debug_child?
282
-
283
- debug_child_puts("[henitai-debug-child] timeout_signal_sent pid=#{pid}")
284
- Process.kill(:USR1, pid)
285
- pause(0.2)
286
- rescue Errno::ESRCH
287
- nil
288
- end
289
-
290
- def install_debug_timeout_trap
291
- Signal.trap("USR1") { debug_child_thread_dump("timeout") }
292
- end
293
-
294
- def debug_child_thread_dump(reason)
295
- return unless debug_child?
296
-
297
- debug_child_puts("[henitai-debug-child] thread_dump reason=#{reason}")
298
- Thread.list.each_with_index do |thread, index|
299
- debug_child_puts(
300
- "[henitai-debug-child] thread index=#{index} id=#{thread.object_id} " \
301
- "status=#{thread.status.inspect}"
302
- )
303
- Array(thread.backtrace).each do |line|
304
- debug_child_puts("[henitai-debug-child] #{line}")
305
- end
306
- end
307
- end
308
- end
309
- # rubocop:enable Metrics/ModuleLength
310
-
311
30
  # Integration adapter for RSpec.
312
31
  #
313
32
  # This class exists as the stable public entry point for the RSpec
@@ -325,226 +44,16 @@ module Henitai
325
44
  raise ArgumentError, "Unknown integration: #{name}. Available: #{available}"
326
45
  end
327
46
 
328
- # Base class for all integrations.
329
- class Base
330
- include ChildDebugSupport
331
-
332
- # @param subject [Subject]
333
- # @return [Array<String>] paths to test files that cover this subject
334
- def select_tests(subject)
335
- raise NotImplementedError
336
- end
337
-
338
- # @return [Array<String>] all test files for the configured framework
339
- def test_files
340
- raise NotImplementedError
341
- end
342
-
343
- # Run test files in a child process with the mutant active.
344
- #
345
- # @param mutant [Mutant]
346
- # @param test_files [Array<String>]
347
- # @param timeout [Float] seconds
348
- # @return [ScenarioExecutionResult]
349
- def run_mutant(mutant:, test_files:, timeout:)
350
- raise NotImplementedError
351
- end
352
-
353
- # Fork a child process for the mutant without waiting for it to finish.
354
- # Returns a ChildHandle carrying the OS pid and log file paths.
355
- # The caller is responsible for waiting and cleanup.
356
- #
357
- # @param mutant [Mutant]
358
- # @param test_files [Array<String>]
359
- # @return [RspecProcessRunner::ChildHandle]
360
- def spawn_mutant(mutant:, test_files:)
361
- raise NotImplementedError
362
- end
363
-
364
- def per_test_coverage_supported?
365
- false
366
- end
367
-
368
- def wait_with_timeout(pid, timeout)
369
- wakeup = Henitai.const_get(:ProcessWakeup).new.install
370
- return Process.last_status if wait_nonblocking(pid)
371
-
372
- wakeup.wait(timeout)
373
- wakeup.drain
374
- return Process.last_status if wait_nonblocking(pid)
375
- return Process.last_status if wait_nonblocking(pid)
376
-
377
- handle_timeout(pid)
378
- ensure
379
- wakeup&.close
380
- end
381
-
382
- def reap_child(pid)
383
- Process.wait(pid)
384
- rescue Errno::ECHILD, Errno::ESRCH
385
- nil
386
- end
387
-
388
- def cleanup_process_group(pid)
389
- grace_period = 2.0
390
- wakeup = Henitai.const_get(:ProcessWakeup).new.install
391
- Process.kill(:SIGTERM, -pid)
392
- return if wait_nonblocking(pid)
393
-
394
- wakeup.wait(grace_period)
395
- wakeup.drain
396
- return if wait_nonblocking(pid)
397
-
398
- Process.kill(:SIGKILL, -pid)
399
- rescue Errno::EPERM
400
- cleanup_child_process(pid)
401
- rescue Errno::ESRCH
402
- nil
403
- ensure
404
- wakeup&.close
405
- end
406
-
407
- private
408
-
409
- def pause(seconds)
410
- sleep(seconds)
411
- end
412
-
413
- def handle_timeout(pid)
414
- begin
415
- debug_child_timeout_dump(pid)
416
- cleanup_process_group(pid)
417
- ensure
418
- reap_child(pid)
419
- end
420
- :timeout
421
- end
422
-
423
- def cleanup_child_process(pid)
424
- grace_period = 2.0
425
- wakeup = Henitai.const_get(:ProcessWakeup).new.install
426
- Process.kill(:SIGTERM, pid)
427
- return if wait_nonblocking(pid)
428
-
429
- wakeup.wait(grace_period)
430
- wakeup.drain
431
- return if wait_nonblocking(pid)
432
-
433
- Process.kill(:SIGKILL, pid)
434
- rescue Errno::EPERM, Errno::ESRCH
435
- nil
436
- ensure
437
- wakeup&.close
438
- end
439
-
440
- def subprocess_env
441
- { "PARALLEL_WORKERS" => "1" }
442
- end
443
-
444
- def wait_nonblocking(pid)
445
- Process.wait(pid, Process::WNOHANG)
446
- rescue Errno::ECHILD, Errno::ESRCH
447
- nil
448
- end
449
-
450
- def scenario_log_support
451
- @scenario_log_support ||= ScenarioLogSupport.new
452
- end
453
-
454
- def with_subprocess_env
455
- original_env = {} # : Hash[String, String?]
456
- subprocess_env.each do |key, value|
457
- original_env[key] = ENV.fetch(key, nil)
458
- ENV[key] = value
459
- end
460
- yield
461
- ensure
462
- restore_subprocess_env(original_env)
463
- end
464
-
465
- def restore_subprocess_env(original_env)
466
- original_env.each do |key, value|
467
- if value.nil?
468
- ENV.delete(key)
469
- else
470
- ENV[key] = value
471
- end
472
- end
473
- end
474
-
475
- def with_non_interactive_stdin
476
- original_stdin = $stdin
477
- $stdin = StringIO.new
478
- yield
479
- ensure
480
- $stdin = original_stdin
481
- end
482
-
483
- def run_tests(test_files)
484
- require "rspec/core"
485
- ::RSpec.__send__(:configuration).fail_if_no_examples = true
486
- debug_child_rspec_trace(test_files:, rspec_options: [], rspec_argv: test_files)
487
- debug_child_example_count("before_run") # steep:ignore Ruby::NoMethod
488
- debug_child_puts("[henitai-debug-child] runner_run_start")
489
- status = run_rspec_runner(test_files)
490
- debug_child_puts("[henitai-debug-child] runner_run_return status=#{status.inspect}")
491
- debug_child_example_count("after_run") # steep:ignore Ruby::NoMethod
492
- debug_child_rspec_exit(status)
493
- return status if status.is_a?(Integer)
494
-
495
- status == true ? 0 : 1
496
- end
497
-
498
- def run_child_activation_and_tests(mutant:, test_files:, log_paths:)
499
- scenario_log_support.with_coverage_dir(mutant.id) do
500
- scenario_log_support.capture_child_output(log_paths) do
501
- debug_child_mutant_meta(mutant) if debug_child?
502
- debug_child_activation_start(mutant.id)
503
- activation_result = Mutant::Activator.activate!(mutant)
504
- debug_child_activation_check if debug_child?
505
- debug_child_activation_end(activation_result, test_files:)
506
- activation_result == :compile_error ? 2 : run_tests(test_files)
507
- end
508
- end
509
- end
510
- end
511
-
512
47
  # RSpec integration adapter.
513
48
  class Rspec < Base
514
- DEFAULT_SUITE_TIMEOUT = 300.0
515
- REQUIRE_DIRECTIVE_PATTERN = /
516
- \A\s*
517
- (require|require_relative)
518
- \s*
519
- (?:\(\s*)?
520
- ["']([^"']+)["']
521
- \s*\)?
522
- /x
49
+ include MutantRunSupport
50
+ include RspecChildRunner
51
+ include RspecTestSelection
523
52
 
524
- def select_tests(subject)
525
- matches = spec_files.select do |path|
526
- content = File.read(path)
527
- selection_patterns(subject).any? { |pattern| content.include?(pattern) }
528
- rescue StandardError
529
- false
530
- end
531
-
532
- return matches unless matches.empty?
533
-
534
- fallback_spec_files(subject)
535
- end
53
+ DEFAULT_SUITE_TIMEOUT = 300.0
536
54
 
537
55
  def test_files = spec_files
538
56
 
539
- def spawn_mutant(mutant:, test_files:)
540
- log_paths = scenario_log_paths(mutant_log_name(mutant))
541
- RspecProcessRunner.new.spawn_mutant(self, mutant:, test_files:, log_paths:)
542
- end
543
-
544
- def run_mutant(mutant:, test_files:, timeout:)
545
- RspecProcessRunner.new.run_mutant(self, mutant:, test_files:, timeout:)
546
- end
547
-
548
57
  def per_test_coverage_supported?
549
58
  true
550
59
  end
@@ -583,130 +92,6 @@ module Henitai
583
92
  RUBY
584
93
  end
585
94
 
586
- def scenario_log_paths(name)
587
- reports_dir = ENV.fetch("HENITAI_REPORTS_DIR", "reports")
588
- log_dir = File.join(reports_dir, "mutation-logs")
589
- {
590
- stdout_path: File.join(log_dir, "#{name}.stdout.log"),
591
- stderr_path: File.join(log_dir, "#{name}.stderr.log"),
592
- log_path: File.join(log_dir, "#{name}.log")
593
- }
594
- end
595
-
596
- def build_result(wait_result, log_paths)
597
- stdout = read_log_file(log_paths[:stdout_path])
598
- stderr = read_log_file(log_paths[:stderr_path])
599
- write_combined_log(log_paths[:log_path], stdout, stderr)
600
-
601
- ScenarioExecutionResult.build(
602
- wait_result:,
603
- stdout:,
604
- stderr:,
605
- log_path: log_paths[:log_path]
606
- )
607
- end
608
-
609
- def spec_files
610
- @spec_files ||= begin
611
- paths = Dir.glob("spec/**/*_spec.rb")
612
- paths - excluded_spec_files
613
- end
614
- end
615
-
616
- def fallback_spec_files(subject)
617
- return [] unless subject.source_file
618
-
619
- matches = spec_files.select do |path|
620
- requires_source_file_transitively?(path, subject.source_file)
621
- rescue StandardError
622
- false
623
- end
624
-
625
- matches.empty? ? spec_files : matches
626
- end
627
-
628
- def excluded_spec_files
629
- @excluded_spec_files ||= rspec_exclude_patterns.flat_map { |pattern| Dir.glob(pattern) }.uniq
630
- end
631
-
632
- def rspec_exclude_patterns
633
- rspec_config_lines.filter_map do |line|
634
- line[/\A--exclude-pattern\s+(.+)\z/, 1]
635
- end
636
- end
637
-
638
- def rspec_config_lines
639
- return [] unless File.exist?(rspec_config_path)
640
-
641
- File.readlines(rspec_config_path, chomp: true).map(&:strip)
642
- end
643
-
644
- def rspec_config_path
645
- ".rspec"
646
- end
647
-
648
- def selection_patterns(subject)
649
- [
650
- subject.expression,
651
- subject.namespace
652
- ].compact.uniq.sort_by(&:length).reverse
653
- end
654
-
655
- def requires_source_file?(spec_file, source_file)
656
- content = File.read(spec_file)
657
- basename = File.basename(source_file, ".rb")
658
- content.include?(basename) || content.include?(source_file)
659
- end
660
-
661
- def requires_source_file_transitively?(spec_file, source_file, visited = [])
662
- normalized_spec_file = File.expand_path(spec_file)
663
- return false if visited.include?(normalized_spec_file)
664
-
665
- visited << normalized_spec_file
666
- return true if requires_source_file?(spec_file, source_file)
667
-
668
- required_files(spec_file).any? do |required_file|
669
- requires_source_file_transitively?(required_file, source_file, visited)
670
- end
671
- end
672
-
673
- def required_files(spec_file)
674
- File.read(spec_file).lines.filter_map do |line|
675
- match = line.match(REQUIRE_DIRECTIVE_PATTERN)
676
- next unless match
677
-
678
- resolve_required_file(spec_file, match[1].to_s, match[2].to_s)
679
- end
680
- end
681
-
682
- def resolve_required_file(spec_file, method_name, required_path)
683
- candidates =
684
- if method_name == "require_relative"
685
- relative_candidates(spec_file, required_path)
686
- else
687
- require_candidates(spec_file, required_path)
688
- end
689
-
690
- candidates.find { |candidate| File.file?(candidate) }
691
- end
692
-
693
- def relative_candidates(spec_file, required_path)
694
- expand_candidates(File.dirname(spec_file), required_path)
695
- end
696
-
697
- def require_candidates(spec_file, required_path)
698
- ([File.dirname(spec_file), Dir.pwd] + $LOAD_PATH).flat_map do |base_path|
699
- expand_candidates(base_path, required_path)
700
- end
701
- end
702
-
703
- def expand_candidates(base_path, required_path)
704
- [
705
- File.expand_path(required_path, base_path),
706
- File.expand_path("#{required_path}.rb", base_path)
707
- ].uniq
708
- end
709
-
710
95
  def spawn_suite_process(test_files, log_paths)
711
96
  File.open(log_paths[:stdout_path], "w") do |stdout_file|
712
97
  File.open(log_paths[:stderr_path], "w") do |stderr_file|
@@ -721,233 +106,24 @@ module Henitai
721
106
  end
722
107
  end
723
108
 
724
- def run_in_child(mutant:, test_files:, log_paths:)
725
- Thread.report_on_exception = false
726
- with_subprocess_env do
727
- suppress_simplecov!
728
- suppress_coverage!
729
- install_debug_timeout_trap if debug_child?
730
- with_non_interactive_stdin do
731
- run_child_activation_and_tests(mutant:, test_files:, log_paths:)
732
- end
733
- end
734
- end
735
-
736
- def mutant_log_name(mutant)
737
- "mutant-#{mutant.id}"
738
- end
739
-
740
- def read_log_file(path)
741
- return "" unless File.exist?(path)
742
-
743
- File.read(path)
744
- end
745
-
746
- def write_combined_log(path, stdout, stderr)
747
- FileUtils.mkdir_p(File.dirname(path))
748
- File.write(path, combined_log(stdout, stderr))
749
- end
750
-
751
- def combined_log(stdout, stderr)
752
- [
753
- (stdout.empty? ? nil : "stdout:\n#{stdout}"),
754
- (stderr.empty? ? nil : "stderr:\n#{stderr}")
755
- ].compact.join("\n")
756
- end
757
- end
758
-
759
- # Stores the child-process log helpers shared by the integration specs.
760
- class ScenarioLogSupport
761
- def read_log_file(path)
762
- return "" unless File.exist?(path)
763
-
764
- File.read(path)
765
- end
766
-
767
- def write_combined_log(path, stdout, stderr)
768
- FileUtils.mkdir_p(File.dirname(path))
769
- File.write(path, combined_log(stdout, stderr))
770
- end
771
-
772
- def combined_log(stdout, stderr)
773
- [
774
- (stdout.empty? ? nil : "stdout:\n#{stdout}"),
775
- (stderr.empty? ? nil : "stderr:\n#{stderr}")
776
- ].compact.join("\n")
777
- end
778
- end
779
-
780
- # Prepended onto SimpleCov's singleton class to turn start into a no-op
781
- # during mutant child runs. Using prepend avoids "method redefined" warnings.
782
- module SimpleCovStartSuppressor
783
- def start(*_args) = nil
784
- end
785
-
786
- # Suppresses expensive and irrelevant coverage startup/teardown during
787
- # mutant child runs. Coverage artifacts are only required during the
788
- # dedicated bootstrap phase.
789
- module CoverageRuntimeSuppressors
790
- def self.suppress_simplecov!
791
- require "simplecov"
792
- sc = Object.const_get(:SimpleCov) # steep:ignore Ruby::UnknownConstant
793
- sc.external_at_exit = true if sc.respond_to?(:external_at_exit=)
794
- return if sc.singleton_class.ancestors.include?(SimpleCovStartSuppressor)
795
-
796
- sc.singleton_class.prepend(SimpleCovStartSuppressor)
797
- rescue LoadError, NameError
798
- nil
799
- end
800
-
801
- def self.suppress_coverage!
802
- require "coverage"
803
- cov = Object.const_get(:Coverage) # steep:ignore Ruby::UnknownConstant
804
- return if cov.singleton_class.ancestors.include?(CoverageStartSuppressor)
805
-
806
- cov.singleton_class.prepend(CoverageStartSuppressor)
807
- rescue LoadError, NameError
808
- nil
809
- end
810
- end
811
-
812
- # Prepended onto the coverage gem's Coverage singleton to turn start
813
- # into a no-op during mutant child runs.
814
- module CoverageStartSuppressor
815
- def start(*_args) = nil
816
- end
817
-
818
- # Minitest integration adapter.
819
- #
820
- # Coverage formatter injection remains implemented in the RSpec child
821
- # runner. Minitest shares selection and execution semantics, but per-test
822
- # coverage collection is not yet wired into this path.
823
- class Minitest < Rspec
824
- def per_test_coverage_supported?
825
- true
826
- end
827
-
828
- def run_mutant(mutant:, test_files:, timeout:)
829
- setup_load_path
830
- super
831
- end
832
-
833
- def spawn_mutant(mutant:, test_files:)
834
- setup_load_path
835
- super
836
- end
837
-
838
- def run_in_child(mutant:, test_files:, log_paths:)
839
- ENV["RAILS_ENV"] = "test" unless ENV["RAILS_ENV"] == "test"
840
- preload_environment
841
- super
842
- end
843
-
844
- def run_suite(test_files, timeout: DEFAULT_SUITE_TIMEOUT)
845
- log_paths = scenario_log_paths("baseline")
846
- pid = spawn_suite_process(test_files, log_paths)
847
- wait_result = wait_with_timeout(pid, timeout)
848
- build_result(wait_result, log_paths)
849
- ensure
850
- cleanup_suite_process(pid, wait_result)
851
- end
852
-
853
109
  private
854
110
 
855
- def suite_command(test_files)
856
- ["bundle", "exec", "ruby", "-I", "test",
857
- "-r", "henitai/minitest_simplecov",
858
- "-r", "henitai/minitest_coverage_hook",
859
- "-e", "ARGV.each { |f| require File.expand_path(f) }",
860
- *test_files]
861
- end
862
-
863
111
  def run_tests(test_files)
864
- suppress_simplecov!
865
- suppress_minitest_autorun!
866
- test_files.each { |file| require File.expand_path(file) }
867
- # @type var empty_args: Array[String]
868
- empty_args = []
869
- status = ::Minitest.run(empty_args)
112
+ require "rspec/core"
113
+ ::RSpec.__send__(:configuration).fail_if_no_examples = true
114
+ debug_child_rspec_trace(test_files:, rspec_options: [], rspec_argv: test_files)
115
+ debug_child_example_count("before_run") # steep:ignore Ruby::NoMethod
116
+ debug_child_puts("[henitai-debug-child] runner_run_start")
117
+ status = run_rspec_runner(test_files)
118
+ debug_child_puts("[henitai-debug-child] runner_run_return status=#{status.inspect}")
119
+ debug_child_example_count("after_run") # steep:ignore Ruby::NoMethod
120
+ debug_child_rspec_exit(status)
870
121
  return status if status.is_a?(Integer)
871
122
 
872
123
  status == true ? 0 : 1
873
124
  end
874
-
875
- def preload_environment
876
- env_file = File.expand_path("config/environment.rb")
877
- require env_file if File.exist?(env_file)
878
- end
879
-
880
- def setup_load_path
881
- test_dir = File.expand_path("test")
882
- $LOAD_PATH.unshift(test_dir) unless $LOAD_PATH.include?(test_dir)
883
- end
884
-
885
- def suppress_minitest_autorun!
886
- require "minitest"
887
- singleton_class = ::Minitest.singleton_class
888
- suppressor = @minitest_autorun_suppressor ||= Module.new.tap do |mod|
889
- mod.define_method(:autorun) { nil }
890
- end
891
- return if singleton_class.ancestors.include?(suppressor)
892
-
893
- singleton_class.prepend(suppressor)
894
- nil
895
- rescue LoadError, NameError
896
- nil
897
- end
898
-
899
- def suppress_simplecov!
900
- require "simplecov"
901
- sc = Object.const_get(:SimpleCov) # steep:ignore Ruby::UnknownConstant
902
- return if sc.singleton_class.ancestors.include?(SimpleCovStartSuppressor)
903
-
904
- sc.singleton_class.prepend(SimpleCovStartSuppressor)
905
- rescue LoadError, NameError
906
- nil
907
- end
908
-
909
- def suppress_coverage!
910
- require "coverage"
911
- cov = Object.const_get(:Coverage) # steep:ignore Ruby::UnknownConstant
912
- return if cov.singleton_class.ancestors.include?(CoverageStartSuppressor)
913
-
914
- cov.singleton_class.prepend(CoverageStartSuppressor)
915
- rescue LoadError, NameError
916
- nil
917
- end
918
-
919
- def subprocess_env
920
- env = super
921
- env["RAILS_ENV"] = "test" unless ENV["RAILS_ENV"] == "test"
922
- env["PARALLEL_WORKERS"] = "1"
923
- env
924
- end
925
-
926
- def spawn_suite_process(test_files, log_paths)
927
- FileUtils.mkdir_p(File.dirname(log_paths[:stdout_path]))
928
- File.open(log_paths[:stdout_path], "w") do |stdout_file|
929
- File.open(log_paths[:stderr_path], "w") do |stderr_file|
930
- Process.spawn(
931
- subprocess_env,
932
- *suite_command(test_files),
933
- out: stdout_file,
934
- err: stderr_file
935
- )
936
- end
937
- end
938
- end
939
-
940
- def cleanup_suite_process(pid, wait_result)
941
- return unless pid
942
-
943
- cleanup_child_process(pid)
944
- reap_child(pid) if wait_result.nil?
945
- end
946
-
947
- def spec_files
948
- (Dir.glob("test/**/*_test.rb") + Dir.glob("test/**/*_spec.rb"))
949
- .reject { |f| f.start_with?("test/system/") }
950
- end
951
125
  end
952
126
  end
953
127
  end
128
+
129
+ require_relative "integration/minitest"