henitai 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +296 -1
  3. data/README.md +109 -16
  4. data/assets/schema/henitai.schema.json +20 -0
  5. data/lib/henitai/arid_node_filter.rb +4 -19
  6. data/lib/henitai/bootstrap_error_message.rb +20 -0
  7. data/lib/henitai/canonical_report_merger.rb +23 -2
  8. data/lib/henitai/cli/run_command.rb +25 -7
  9. data/lib/henitai/cli/run_options.rb +1 -1
  10. data/lib/henitai/cli.rb +4 -1
  11. data/lib/henitai/configuration.rb +4 -1
  12. data/lib/henitai/configuration_validator/rules.rb +8 -0
  13. data/lib/henitai/configuration_validator/scalars.rb +9 -0
  14. data/lib/henitai/configuration_validator.rb +2 -1
  15. data/lib/henitai/constant_path.rb +48 -0
  16. data/lib/henitai/control_run.rb +73 -0
  17. data/lib/henitai/coverage_bootstrapper.rb +57 -15
  18. data/lib/henitai/coverage_formatter.rb +26 -3
  19. data/lib/henitai/coverage_report_reader.rb +21 -0
  20. data/lib/henitai/equivalence_detector/operand_predicates.rb +16 -0
  21. data/lib/henitai/equivalence_detector.rb +46 -38
  22. data/lib/henitai/example_summary.rb +24 -0
  23. data/lib/henitai/execution_engine/fork_server_scope.rb +57 -0
  24. data/lib/henitai/execution_engine.rb +75 -60
  25. data/lib/henitai/fidelity_check.rb +173 -0
  26. data/lib/henitai/flaky_detection_baseline.rb +61 -0
  27. data/lib/henitai/flaky_mutant_retrier.rb +43 -0
  28. data/lib/henitai/flaky_retry_decision.rb +23 -0
  29. data/lib/henitai/flaky_test_registry.rb +63 -0
  30. data/lib/henitai/fork_server/client.rb +231 -0
  31. data/lib/henitai/fork_server/group_killer.rb +48 -0
  32. data/lib/henitai/fork_server/group_terminator.rb +47 -0
  33. data/lib/henitai/fork_server/launcher.rb +38 -0
  34. data/lib/henitai/fork_server/mutant_runner.rb +141 -0
  35. data/lib/henitai/fork_server/protocol.rb +73 -0
  36. data/lib/henitai/fork_server/reaper.rb +24 -0
  37. data/lib/henitai/fork_server/runtime.rb +109 -0
  38. data/lib/henitai/fork_server/server.rb +108 -0
  39. data/lib/henitai/fork_server/server_process.rb +73 -0
  40. data/lib/henitai/fork_server/status.rb +52 -0
  41. data/lib/henitai/fork_server.rb +43 -0
  42. data/lib/henitai/generated_artifacts.rb +3 -0
  43. data/lib/henitai/git_diff_analyzer.rb +28 -11
  44. data/lib/henitai/incremental_filter.rb +20 -10
  45. data/lib/henitai/integration/base.rb +72 -5
  46. data/lib/henitai/integration/capped_log_sink.rb +34 -0
  47. data/lib/henitai/integration/child_bootstrap.rb +7 -0
  48. data/lib/henitai/integration/child_report_store.rb +36 -0
  49. data/lib/henitai/integration/minitest.rb +17 -7
  50. data/lib/henitai/integration/minitest_autorun.rb +20 -0
  51. data/lib/henitai/integration/minitest_outcome_reporter.rb +28 -0
  52. data/lib/henitai/integration/minitest_preload.rb +30 -0
  53. data/lib/henitai/integration/minitest_runnable_filter.rb +61 -0
  54. data/lib/henitai/integration/minitest_test_runner.rb +32 -15
  55. data/lib/henitai/integration/mutant_run_support.rb +73 -13
  56. data/lib/henitai/integration/rspec_child_runner.rb +44 -9
  57. data/lib/henitai/integration/rspec_preload.rb +35 -0
  58. data/lib/henitai/integration/rspec_process_runner.rb +4 -1
  59. data/lib/henitai/integration/scenario_log_support.rb +31 -18
  60. data/lib/henitai/integration.rb +16 -3
  61. data/lib/henitai/minitest_coverage_reporter.rb +22 -6
  62. data/lib/henitai/minitest_result_file.rb +62 -0
  63. data/lib/henitai/mutant/activation.rb +32 -0
  64. data/lib/henitai/mutant/activator.rb +75 -55
  65. data/lib/henitai/mutant/def_injection.rb +136 -0
  66. data/lib/henitai/mutant/lexical_scope_check.rb +120 -0
  67. data/lib/henitai/mutant/lexical_target.rb +105 -0
  68. data/lib/henitai/mutant/replacement.rb +52 -0
  69. data/lib/henitai/mutant.rb +27 -8
  70. data/lib/henitai/mutant_generator.rb +62 -18
  71. data/lib/henitai/mutant_history_store/verdict_cache.rb +12 -11
  72. data/lib/henitai/mutant_history_store.rb +14 -4
  73. data/lib/henitai/mutant_identity.rb +5 -1
  74. data/lib/henitai/mutant_verdict.rb +71 -0
  75. data/lib/henitai/operators/array_declaration.rb +4 -0
  76. data/lib/henitai/operators/assignment_expression.rb +2 -0
  77. data/lib/henitai/operators/conditional_expression.rb +21 -1
  78. data/lib/henitai/operators/hash_literal.rb +7 -4
  79. data/lib/henitai/operators/method_expression.rb +14 -3
  80. data/lib/henitai/operators/regex_metacharacters.rb +89 -0
  81. data/lib/henitai/operators/regex_mutator.rb +23 -28
  82. data/lib/henitai/operators/return_value.rb +1 -1
  83. data/lib/henitai/operators.rb +1 -0
  84. data/lib/henitai/per_test_coverage.rb +11 -7
  85. data/lib/henitai/per_test_coverage_collector.rb +30 -31
  86. data/lib/henitai/per_test_coverage_delta.rb +60 -0
  87. data/lib/henitai/per_test_coverage_selector.rb +26 -13
  88. data/lib/henitai/process_wakeup.rb +8 -3
  89. data/lib/henitai/process_worker_runner.rb +19 -5
  90. data/lib/henitai/reporter.rb +15 -8
  91. data/lib/henitai/result.rb +32 -16
  92. data/lib/henitai/reuse_context.rb +50 -0
  93. data/lib/henitai/rspec_coverage_formatter.rb +4 -0
  94. data/lib/henitai/rspec_outcomes.rb +72 -0
  95. data/lib/henitai/runner.rb +58 -19
  96. data/lib/henitai/runner_dependencies.rb +22 -1
  97. data/lib/henitai/sampling_strategy.rb +36 -6
  98. data/lib/henitai/scenario_execution_result.rb +28 -5
  99. data/lib/henitai/signature_line.rb +21 -0
  100. data/lib/henitai/slot_scheduler/draining.rb +12 -2
  101. data/lib/henitai/slot_scheduler/process_control.rb +33 -0
  102. data/lib/henitai/slot_scheduler/retry_policy.rb +8 -6
  103. data/lib/henitai/slot_scheduler.rb +47 -8
  104. data/lib/henitai/source_file_selection.rb +1 -1
  105. data/lib/henitai/source_parser.rb +29 -9
  106. data/lib/henitai/static_filter.rb +45 -14
  107. data/lib/henitai/stillborn_filter.rb +70 -2
  108. data/lib/henitai/subject.rb +8 -3
  109. data/lib/henitai/subject_resolver.rb +65 -31
  110. data/lib/henitai/suite_boot_clock.rb +48 -0
  111. data/lib/henitai/survivor_activation_cache.rb +18 -11
  112. data/lib/henitai/survivor_rerun_strategy.rb +47 -43
  113. data/lib/henitai/test_file_outcomes.rb +79 -0
  114. data/lib/henitai/timeout_calibrator.rb +31 -9
  115. data/lib/henitai/timeout_ceiling.rb +37 -0
  116. data/lib/henitai/verdict_fingerprint.rb +29 -30
  117. data/lib/henitai/version.rb +1 -1
  118. data/lib/henitai.rb +17 -1
  119. data/sig/configuration_validator.rbs +1 -0
  120. data/sig/henitai.rbs +227 -45
  121. metadata +46 -3
  122. data/lib/henitai/survivor_test_filter.rb +0 -72
@@ -10,8 +10,11 @@ module Henitai
10
10
  # The framework-specific test invocation is delegated to #run_tests, which
11
11
  # including classes must implement.
12
12
  module MutantRunSupport
13
+ # The previous attempt's report is cleared before the fork, so a retried
14
+ # child that dies early can never be classified by stale data.
13
15
  def spawn_mutant(mutant:, test_files:)
14
- log_paths = scenario_log_paths(mutant_log_name(mutant))
16
+ log_paths = mutant_log_paths(mutant)
17
+ child_report_store.clear(log_paths[:report_path])
15
18
  RspecProcessRunner.new.spawn_mutant(self, mutant:, test_files:, log_paths:)
16
19
  end
17
20
 
@@ -29,17 +32,22 @@ module Henitai
29
32
  }
30
33
  end
31
34
 
35
+ # Mutant runs carry a report path and are classified from the child's
36
+ # report; the baseline suite has none and keeps exit-status semantics.
37
+ def mutant_log_paths(mutant)
38
+ log_paths = scenario_log_paths(mutant_log_name(mutant))
39
+ log_paths.merge(report_path: log_paths[:log_path].sub(/\.log\z/, ".report.json"))
40
+ end
41
+
32
42
  def build_result(wait_result, log_paths)
33
43
  stdout = scenario_log_support.read_log_file(log_paths[:stdout_path])
34
44
  stderr = scenario_log_support.read_log_file(log_paths[:stderr_path])
35
45
  scenario_log_support.write_combined_log(log_paths[:log_path], stdout, stderr)
46
+ output = { stdout:, stderr:, log_path: log_paths[:log_path] }
47
+ return ScenarioExecutionResult.build(wait_result:, **output) unless log_paths.key?(:report_path)
36
48
 
37
- ScenarioExecutionResult.build(
38
- wait_result:,
39
- stdout:,
40
- stderr:,
41
- log_path: log_paths[:log_path]
42
- )
49
+ report = child_report_store.read(log_paths[:report_path])
50
+ ScenarioExecutionResult.build_for_mutant(wait_result:, report:, **output)
43
51
  end
44
52
 
45
53
  def run_in_child(mutant:, test_files:, log_paths:)
@@ -61,17 +69,69 @@ module Henitai
61
69
  private
62
70
 
63
71
  def run_child_activation_and_tests(mutant:, test_files:, log_paths:)
72
+ report = ->(data) { child_report_store.write(log_paths[:report_path], data) }
64
73
  scenario_log_support.with_coverage_dir(mutant.id) do
65
74
  scenario_log_support.capture_child_output(log_paths) do
66
- child_debug_log.mutant_meta(mutant)
67
- child_debug_log.activation_start(mutant.id)
68
- activation_result = Mutant::Activator.activate!(mutant)
69
- child_debug_log.activation_check
70
- child_debug_log.activation_end(activation_result, test_files:)
71
- activation_result == :compile_error ? 2 : run_tests(test_files)
75
+ run_tests_in_child(mutant, test_files, report)
72
76
  end
73
77
  end
74
78
  end
79
+
80
+ # Any failure to put the mutant in place is a harness outcome: the tests
81
+ # never ran against the mutation, so nothing may count as a kill.
82
+ def activate_in_child(mutant, test_files, report)
83
+ return true unless activate_with_debug_log(mutant, test_files) == :compile_error
84
+
85
+ report.call("outcome" => "activation_failed", "reason" => "the activation source does not compile")
86
+ false
87
+ rescue StandardError, ScriptError => e
88
+ warn "henitai: mutant activation failed: #{e.class}: #{e.message}"
89
+ report.call("outcome" => "activation_failed", "reason" => "#{e.class}: #{e.message}")
90
+ false
91
+ end
92
+
93
+ def activate_with_debug_log(mutant, test_files)
94
+ log = child_debug_log
95
+ log.mutant_meta(mutant)
96
+ log.activation_start(mutant.id)
97
+ activation_result = Mutant::Activator.activate!(mutant)
98
+ log.activation_check
99
+ log.activation_end(activation_result, test_files:)
100
+ activation_result
101
+ end
102
+
103
+ # RSpec and Minitest let SystemExit through by design. Raised by the
104
+ # code under test it ends the run midway, so it is reported instead of
105
+ # becoming the child's exit status.
106
+ #
107
+ # The mutant is activated from #run_tests' after-load hook: once the test
108
+ # files (and the code they require) are loaded, before any test runs.
109
+ # Activating earlier would load the subject file on its own, without the
110
+ # superclasses and constants the rest of the application defines.
111
+ def run_tests_in_child(mutant, test_files, report)
112
+ activated = nil
113
+ outcomes = TestFileOutcomes.new
114
+ exit_code = run_tests(test_files, outcomes) { activated = activate_in_child(mutant, test_files, report) }
115
+ return finish_without_tests(activated, report) unless activated
116
+
117
+ report.call(
118
+ "outcome" => "tests_finished",
119
+ "exit_code" => exit_code,
120
+ "failed_tests" => outcomes.failed_files,
121
+ "unattributed_failures" => outcomes.unattributed_failures?
122
+ )
123
+ exit_code
124
+ rescue SystemExit => e
125
+ report.call("outcome" => "system_exit", "status" => e.status)
126
+ 1
127
+ end
128
+
129
+ # A nil activation means the hook never ran: the test files failed to
130
+ # load, before the mutant was in place.
131
+ def finish_without_tests(activated, report)
132
+ report.call("outcome" => "environment_failed") if activated.nil?
133
+ 2
134
+ end
75
135
  end
76
136
  end
77
137
  end
@@ -8,7 +8,20 @@ module Henitai
8
8
  module RspecChildRunner
9
9
  private
10
10
 
11
- def run_rspec_runner(test_files)
11
+ def run_rspec_runner(test_files, &after_load)
12
+ log = child_debug_log
13
+ runner = @preloaded_runner || prepare_rspec_runner(test_files)
14
+ return 1 unless ready_to_run?(after_load)
15
+
16
+ run_rspec_specs(runner, example_groups_to_run(test_files))
17
+ rescue SystemExit => e
18
+ log.write("[henitai-debug-child] runner_run_system_exit status=#{e.status.inspect}")
19
+ raise
20
+ ensure
21
+ child_debug_log.write("[henitai-debug-child] runner_run_ensure")
22
+ end
23
+
24
+ def prepare_rspec_runner(test_files)
12
25
  log = child_debug_log
13
26
  log.write("[henitai-debug-child] build_rspec_runner_start")
14
27
  runner = build_rspec_runner
@@ -17,12 +30,34 @@ module Henitai
17
30
  configure_rspec_runner(runner)
18
31
  log.write("[henitai-debug-child] configure_rspec_runner_return")
19
32
  load_rspec_spec_files(test_files)
20
- run_rspec_specs(runner)
21
- rescue SystemExit => e
22
- log.write("[henitai-debug-child] runner_run_system_exit status=#{e.status.inspect}")
23
- raise
24
- ensure
25
- child_debug_log.write("[henitai-debug-child] runner_run_ensure")
33
+ runner
34
+ end
35
+
36
+ # A fork-server process loaded the union of the run's spec files; each
37
+ # mutant process runs only the groups defined in its own files.
38
+ def example_groups_to_run(test_files)
39
+ groups = ::RSpec.world.ordered_example_groups
40
+ return groups unless @preloaded_runner
41
+
42
+ wanted = test_files.map { |file| File.expand_path(file) }
43
+ groups.select { |group| wanted.include?(example_group_file(group)) }
44
+ end
45
+
46
+ def example_group_file(group)
47
+ metadata = group.metadata
48
+ metadata[:absolute_file_path] || File.expand_path(metadata[:file_path])
49
+ end
50
+
51
+ # The after-load hook (mutant activation) runs only when every spec file
52
+ # loaded; a falsy hook result also skips the run.
53
+ def ready_to_run?(after_load)
54
+ rspec_loaded_cleanly? && (after_load.nil? || after_load.call)
55
+ end
56
+
57
+ # RSpec records a spec file that failed to load and quits before running.
58
+ def rspec_loaded_cleanly?
59
+ world = ::RSpec.world
60
+ !(world.wants_to_quit || world.rspec_is_quitting)
26
61
  end
27
62
 
28
63
  def build_rspec_runner
@@ -51,9 +86,9 @@ module Henitai
51
86
  child_debug_log.write("[henitai-debug-child] load_spec_files_return")
52
87
  end
53
88
 
54
- def run_rspec_specs(runner)
89
+ def run_rspec_specs(runner, example_groups)
55
90
  child_debug_log.write("[henitai-debug-child] run_specs_start")
56
- result = runner.send(:run_specs, ::RSpec.world.ordered_example_groups)
91
+ result = runner.send(:run_specs, example_groups)
57
92
  child_debug_log.write("[henitai-debug-child] run_specs_return result=#{result.inspect}")
58
93
  result
59
94
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ module Integration
5
+ # Lets the fork server (ADR-15) boot the RSpec environment once. The
6
+ # booted runner stays in the server; every forked mutant process then runs
7
+ # its own subset of the loaded example groups.
8
+ module RspecPreload
9
+ class PreloadError < StandardError; end
10
+
11
+ def fork_server_supported?
12
+ true
13
+ end
14
+
15
+ # Loads RSpec, its configured requires (spec_helper, rails_helper, ...)
16
+ # and the given spec files, without activating a mutant or running a
17
+ # test. Coverage startup is suppressed before anything loads, as it is
18
+ # in every mutant child.
19
+ #
20
+ # @raise [PreloadError] when a spec file failed to load
21
+ def preload_environment(test_files)
22
+ CoverageRuntimeSuppressors.suppress_simplecov!
23
+ CoverageRuntimeSuppressors.suppress_coverage!
24
+ subprocess_env.each { |key, value| ENV[key] = value }
25
+ require "rspec/core"
26
+ ::RSpec.configuration.fail_if_no_examples = true
27
+ runner = prepare_rspec_runner(test_files)
28
+ raise PreloadError, "the spec files failed to load" unless rspec_loaded_cleanly?
29
+
30
+ @preloaded_runner = runner
31
+ nil
32
+ end
33
+ end
34
+ end
35
+ end
@@ -52,7 +52,10 @@ module Henitai
52
52
  end
53
53
 
54
54
  # Captures the PID and log paths for a spawned mutant child process.
55
- ChildHandle = Struct.new(:pid, :log_paths)
55
+ # +timeout+ is set only when the spawner knows better than the scheduler's
56
+ # resolver how long this child may run (a fallback child that boots the
57
+ # suite itself while the fork server is active).
58
+ ChildHandle = Struct.new(:pid, :log_paths, :timeout)
56
59
 
57
60
  # Runs RSpec child and suite processes on behalf of the integration.
58
61
  class RspecProcessRunner
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require_relative "capped_log_sink"
4
5
 
5
6
  module Henitai
6
7
  module Integration
@@ -12,6 +13,9 @@ module Henitai
12
13
  MAX_LOG_BYTES_ENV = "HENITAI_MAX_LOG_BYTES"
13
14
  DEFAULT_MAX_LOG_BYTES = 5_000_000
14
15
  DRAIN_CHUNK_BYTES = 65_536
16
+ # Bound on the reads of the final drain, so a grandchild that keeps
17
+ # writing cannot hold the close up.
18
+ DRAIN_AVAILABLE_READS = 16
15
19
 
16
20
  def capture_child_output(log_paths)
17
21
  output_files = open_child_output(log_paths)
@@ -69,37 +73,46 @@ module Henitai
69
73
  file = File.new(path, "wb")
70
74
  file.sync = true
71
75
  reader, writer = IO.pipe
72
- thread = Thread.new { drain_capped(reader, file, cap) }
73
- { file:, reader:, writer:, thread: }
74
- end
75
-
76
- def drain_capped(reader, file, cap)
77
- written = 0
78
- truncated = false
76
+ sink = CappedLogSink.new(file, cap)
77
+ stop_reader, stop_writer = IO.pipe
78
+ thread = Thread.new { drain_capped(reader, stop_reader, sink) }
79
+ { file:, reader:, writer:, stop_reader:, stop_writer:, thread: }
80
+ end
81
+
82
+ # Drains until EOF or until +stop_reader+ becomes readable. Never killed:
83
+ # the stop signal ends it cooperatively between sink writes.
84
+ # Uses Kernel#select rather than IO.select: the drain runs beside the
85
+ # tests under mutation, and specs that stub or count IO.select must not
86
+ # see the harness's own waits.
87
+ def drain_capped(reader, stop_reader, sink)
79
88
  loop do
80
- chunk = reader.readpartial(DRAIN_CHUNK_BYTES)
81
- next unless written < cap
82
-
83
- slice = chunk.byteslice(0, cap - written)
84
- file.write(slice)
85
- written += slice.bytesize
86
- next unless written >= cap && !truncated
89
+ ready, = select([reader, stop_reader])
90
+ break drain_available(reader, sink) if ready.include?(stop_reader)
87
91
 
88
- file.write("\n[henitai] output truncated at #{cap} bytes\n")
89
- truncated = true
92
+ sink.write(reader.readpartial(DRAIN_CHUNK_BYTES))
90
93
  end
91
94
  rescue IOError
92
95
  # EOFError (a subclass) on writer close, or a closed pipe: draining done.
93
96
  nil
94
97
  end
95
98
 
99
+ # Closing the writer lets the drain thread reach EOF. A grandchild may
100
+ # still hold a copy of the writer, so EOF is not awaited: the stop pipe
101
+ # makes the thread take what is readable right now and exit.
96
102
  def finish_capped_stream(stream)
97
103
  return unless stream
98
104
 
99
105
  stream[:writer].close
106
+ stream[:stop_writer].close
100
107
  stream[:thread].join
101
- stream[:reader].close
102
- stream[:file].close
108
+ %i[reader stop_reader file].each { |key| stream[key].close }
109
+ end
110
+
111
+ def drain_available(reader, sink)
112
+ DRAIN_AVAILABLE_READS.times { sink.write(reader.read_nonblock(DRAIN_CHUNK_BYTES)) }
113
+ rescue IO::WaitReadable, IOError
114
+ # Nothing more buffered right now, or the pipe is closed.
115
+ nil
103
116
  end
104
117
 
105
118
  def redirect_child_output(output_files)
@@ -6,12 +6,15 @@ require_relative "process_wakeup"
6
6
  require_relative "integration/child_bootstrap"
7
7
  require_relative "integration/rspec_process_runner"
8
8
  require_relative "integration/scenario_log_support"
9
+ require_relative "integration/child_report_store"
9
10
  require_relative "integration/coverage_suppression"
10
11
  require_relative "integration/child_debug_log"
11
12
  require_relative "integration/loaded_features"
12
13
  require_relative "integration/base"
13
14
  require_relative "integration/mutant_run_support"
14
15
  require_relative "integration/rspec_child_runner"
16
+ require_relative "rspec_outcomes"
17
+ require_relative "integration/rspec_preload"
15
18
  require_relative "integration/rspec_test_selection"
16
19
 
17
20
  module Henitai
@@ -50,6 +53,7 @@ module Henitai
50
53
  class Rspec < Base
51
54
  include MutantRunSupport
52
55
  include RspecChildRunner
56
+ include RspecPreload
53
57
  include RspecTestSelection
54
58
 
55
59
  DEFAULT_SUITE_TIMEOUT = 300.0
@@ -98,7 +102,7 @@ module Henitai
98
102
  File.open(log_paths[:stdout_path], "w") do |stdout_file|
99
103
  File.open(log_paths[:stderr_path], "w") do |stderr_file|
100
104
  Process.spawn(
101
- subprocess_env,
105
+ suite_process_env,
102
106
  *suite_command(test_files),
103
107
  out: stdout_file,
104
108
  err: stderr_file,
@@ -110,14 +114,23 @@ module Henitai
110
114
 
111
115
  private
112
116
 
113
- def run_tests(test_files)
117
+ def outcome_recording_hook(outcomes, after_load)
118
+ lambda do
119
+ RspecOutcomes.register(outcomes, ::RSpec.__send__(:configuration).reporter)
120
+ after_load.nil? || after_load.call
121
+ end
122
+ end
123
+
124
+ # Yields once the spec files are loaded and before they run; a falsy
125
+ # block result skips the run (see MutantRunSupport#run_tests_in_child).
126
+ def run_tests(test_files, outcomes, &after_load)
114
127
  require "rspec/core"
115
128
  ::RSpec.__send__(:configuration).fail_if_no_examples = true
116
129
  log = child_debug_log
117
130
  log.rspec_trace(test_files:, rspec_options: [], rspec_argv: test_files)
118
131
  log.example_count("before_run")
119
132
  log.write("[henitai-debug-child] runner_run_start")
120
- status = run_rspec_runner(test_files)
133
+ status = run_rspec_runner(test_files, &outcome_recording_hook(outcomes, after_load))
121
134
  log.write("[henitai-debug-child] runner_run_return status=#{status.inspect}")
122
135
  log.example_count("after_run")
123
136
  log.rspec_exit(status)
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "minitest"
4
4
  require "henitai/per_test_coverage_collector"
5
+ require "henitai/test_file_outcomes"
6
+ require "henitai/minitest_result_file"
5
7
 
6
8
  module Henitai
7
9
  # Minitest reporter that collects per-test line coverage deltas.
@@ -11,26 +13,40 @@ module Henitai
11
13
  # to PerTestCoverageCollector so the JSON output format is identical to the
12
14
  # RSpec integration.
13
15
  class MinitestCoverageReporter < Minitest::Reporter
14
- def initialize(io = $stdout, options = {})
16
+ def initialize(io = $stdout, options = {}, boot_clock: SuiteBootClock.from_env(ENV))
17
+ super(io, options)
18
+ @collector = PerTestCoverageCollector.new(boot_clock:)
19
+ @outcomes = TestFileOutcomes.new
20
+ end
21
+
22
+ def prerecord(klass, name)
15
23
  super
16
- @collector = PerTestCoverageCollector.new
24
+ @collector.test_started
17
25
  end
18
26
 
19
27
  def record(result)
20
28
  super
21
- @collector.record_test(
22
- relative_to_pwd(result.source_location.first),
23
- duration: result.time
24
- )
29
+ record_coverage(result)
30
+ MinitestResultFile.record(@outcomes, result)
25
31
  end
26
32
 
27
33
  def report
28
34
  super
29
35
  @collector.write_report
36
+ @outcomes.write(TestFileOutcomes.default_report_path)
30
37
  end
31
38
 
32
39
  private
33
40
 
41
+ # A result without a resolvable file is skipped, so its coverage delta is
42
+ # not attributed to the next recorded test.
43
+ def record_coverage(result)
44
+ file = MinitestResultFile.for(result)
45
+ return @collector.skip_test unless file
46
+
47
+ @collector.record_test(relative_to_pwd(file), duration: result.time)
48
+ end
49
+
34
50
  def relative_to_pwd(path)
35
51
  prefix = "#{Dir.pwd}#{File::SEPARATOR}"
36
52
  path.start_with?(prefix) ? path.sub(prefix, "") : path
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Finds the test file a Minitest result belongs to.
5
+ #
6
+ # `Result#source_location` is where the test method is defined. For tests
7
+ # inherited from a base class or an included module that is a shared file,
8
+ # not the file of the test class that ran, so the file that defines the
9
+ # result's class is used for those. A method defined directly on the class
10
+ # keeps its own file: the class may have been reopened in another test file.
11
+ # The method location is also the fallback for classes that cannot be
12
+ # resolved (anonymous or removed).
13
+ module MinitestResultFile
14
+ module_function
15
+
16
+ # Records the result's outcome; a failure with no usable file is
17
+ # recorded as unattributed rather than dropped.
18
+ def record(outcomes, result)
19
+ failed = !result.passed? && !result.skipped?
20
+ file = self.for(result)
21
+ if file
22
+ outcomes.record(file, failed:)
23
+ elsif failed
24
+ outcomes.record_unattributed_failure
25
+ end
26
+ end
27
+
28
+ # @return [String, nil] the test file, or nil when none can be found
29
+ def for(result)
30
+ return usable(method_file(result)) || usable(class_file(result)) if defined_on_class?(result)
31
+
32
+ usable(class_file(result)) || usable(method_file(result))
33
+ end
34
+
35
+ def defined_on_class?(result)
36
+ klass = Object.const_get(result.klass.to_s)
37
+ klass.instance_method(result.name).owner == klass
38
+ rescue NameError
39
+ false
40
+ end
41
+
42
+ def class_file(result)
43
+ class_file_of(result.klass.to_s)
44
+ end
45
+
46
+ # The file that defines the named class; nil for anonymous or unknown names.
47
+ def class_file_of(name)
48
+ usable(Object.const_source_location(name)&.first)
49
+ rescue NameError
50
+ nil
51
+ end
52
+
53
+ def method_file(result)
54
+ result.source_location&.first
55
+ end
56
+
57
+ # Minitest reports no location for some results and "unknown" for others.
58
+ def usable(file)
59
+ file if file.is_a?(String) && !file.empty? && file != "unknown" && file != "(eval)"
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ class Mutant
5
+ # The Ruby source that puts a mutant in place, and how to evaluate it.
6
+ #
7
+ # +mode+ is :toplevel for a real +def+ re-evaluated inside the original
8
+ # module and class openers (ADR-13), evaluated at the top level with
9
+ # +file+ and +line+ so +__FILE__+, +__LINE__+ and backtraces stay those of
10
+ # the original source; +def_offset+ is the index of the +def+ line within
11
+ # +source+. +mode+ is :class_eval for the +define_method+ fallback,
12
+ # evaluated inside the target module.
13
+ #
14
+ # For :toplevel, +lexical_scope+ holds the opener lines (e.g. "module A",
15
+ # "class B"), +owner+ the expression the +def+ is defined on ("self" or
16
+ # the receiver of +def Other.name+), and +singleton+ whether it is a
17
+ # +defs+. They let a replayed recipe reopen the scope without the AST.
18
+ Activation = Struct.new(:source, :mode, :file, :line, :def_offset, :lexical_scope, :owner, :singleton,
19
+ keyword_init: true) do
20
+ def self.from_h(hash)
21
+ new(source: hash.fetch("source"), mode: hash.fetch("mode").to_sym, file: hash["file"],
22
+ line: hash["line"], def_offset: hash["defOffset"], lexical_scope: hash["lexicalScope"],
23
+ owner: hash["owner"], singleton: hash["singleton"])
24
+ end
25
+
26
+ def to_h
27
+ { "source" => source, "mode" => mode.to_s, "file" => file, "line" => line, "defOffset" => def_offset,
28
+ "lexicalScope" => lexical_scope, "owner" => owner, "singleton" => singleton }
29
+ end
30
+ end
31
+ end
32
+ end