henitai 0.2.0 → 0.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +105 -1
- data/README.md +138 -5
- data/assets/schema/henitai.schema.json +41 -0
- data/lib/henitai/available_cpu_count.rb +16 -23
- data/lib/henitai/canonical_report_merger.rb +107 -0
- data/lib/henitai/canonical_report_writer.rb +22 -0
- data/lib/henitai/checkpoint_reporter.rb +79 -0
- data/lib/henitai/cli/clean_command.rb +54 -0
- data/lib/henitai/cli/command_support.rb +52 -0
- data/lib/henitai/cli/init_command.rb +64 -0
- data/lib/henitai/cli/operator_command.rb +96 -0
- data/lib/henitai/cli/options.rb +84 -0
- data/lib/henitai/cli/run_command.rb +136 -0
- data/lib/henitai/cli/run_options.rb +108 -0
- data/lib/henitai/cli.rb +27 -407
- data/lib/henitai/composite_progress_reporter.rb +42 -0
- data/lib/henitai/configuration.rb +42 -9
- data/lib/henitai/configuration_validator/rules.rb +161 -0
- data/lib/henitai/configuration_validator/scalars.rb +169 -0
- data/lib/henitai/configuration_validator.rb +20 -240
- data/lib/henitai/coverage_bootstrapper.rb +54 -5
- data/lib/henitai/coverage_formatter.rb +5 -1
- data/lib/henitai/coverage_report_reader.rb +28 -5
- data/lib/henitai/eager_load.rb +36 -5
- data/lib/henitai/execution_engine/env_scope.rb +67 -0
- data/lib/henitai/execution_engine.rb +115 -53
- data/lib/henitai/generated_artifacts.rb +58 -0
- data/lib/henitai/incremental_filter.rb +100 -0
- data/lib/henitai/integration/base.rb +171 -0
- data/lib/henitai/integration/child_debug_support.rb +119 -0
- data/lib/henitai/integration/child_runtime_control.rb +50 -0
- data/lib/henitai/integration/coverage_suppression.rb +52 -0
- data/lib/henitai/integration/minitest.rb +101 -0
- data/lib/henitai/integration/minitest_load_path.rb +14 -0
- data/lib/henitai/integration/minitest_suite_command.rb +17 -0
- data/lib/henitai/integration/minitest_test_runner.rb +39 -0
- data/lib/henitai/integration/mutant_run_support.rb +77 -0
- data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
- data/lib/henitai/integration/rspec_child_runner.rb +61 -0
- data/lib/henitai/integration/rspec_test_selection.rb +138 -0
- data/lib/henitai/integration/scenario_log_support.rb +160 -0
- data/lib/henitai/integration.rb +22 -846
- data/lib/henitai/minitest_coverage_reporter.rb +4 -1
- data/lib/henitai/mutant/activator.rb +15 -79
- data/lib/henitai/mutant/parameter_source.rb +98 -0
- data/lib/henitai/mutant.rb +14 -6
- data/lib/henitai/mutant_history_store/sql.rb +90 -0
- data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
- data/lib/henitai/mutant_history_store.rb +67 -81
- data/lib/henitai/mutant_identity.rb +33 -2
- data/lib/henitai/mutation_skip_directives.rb +227 -0
- data/lib/henitai/operator.rb +3 -1
- data/lib/henitai/operators/equality_identity_operator.rb +51 -0
- data/lib/henitai/operators/equality_operator.rb +8 -3
- data/lib/henitai/operators.rb +1 -0
- data/lib/henitai/per_test_coverage.rb +66 -0
- data/lib/henitai/per_test_coverage_collector.rb +23 -6
- data/lib/henitai/per_test_coverage_selector.rb +11 -33
- data/lib/henitai/process_worker_runner.rb +48 -334
- data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
- data/lib/henitai/reporter.rb +258 -125
- data/lib/henitai/reports_directory_lock.rb +76 -0
- data/lib/henitai/result.rb +88 -23
- data/lib/henitai/runner.rb +137 -218
- data/lib/henitai/scenario_execution_result.rb +12 -0
- data/lib/henitai/slot_scheduler/draining.rb +146 -0
- data/lib/henitai/slot_scheduler/process_control.rb +43 -0
- data/lib/henitai/slot_scheduler.rb +272 -0
- data/lib/henitai/static_filter.rb +16 -6
- data/lib/henitai/survivor_rerun_strategy.rb +195 -0
- data/lib/henitai/survivor_test_filter.rb +1 -1
- data/lib/henitai/test_prioritizer.rb +28 -2
- data/lib/henitai/timeout_calibrator.rb +44 -0
- data/lib/henitai/unparse_helper.rb +5 -2
- data/lib/henitai/verdict_fingerprint.rb +155 -0
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +16 -1
- data/sig/configuration_validator.rbs +46 -22
- data/sig/henitai.rbs +386 -96
- metadata +45 -3
- data/lib/henitai/parallel_execution_runner.rb +0 -153
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
module Integration
|
|
5
|
+
# Drives an in-process RSpec run inside the mutant child. Kept separate
|
|
6
|
+
# from the framework-agnostic debug helpers so the RSpec-specific runner
|
|
7
|
+
# wiring stays in one place.
|
|
8
|
+
module RspecChildRunner
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def run_rspec_runner(test_files)
|
|
12
|
+
debug_child_puts("[henitai-debug-child] build_rspec_runner_start")
|
|
13
|
+
runner = build_rspec_runner
|
|
14
|
+
debug_child_puts("[henitai-debug-child] build_rspec_runner_return")
|
|
15
|
+
debug_child_puts("[henitai-debug-child] configure_rspec_runner_start")
|
|
16
|
+
configure_rspec_runner(runner)
|
|
17
|
+
debug_child_puts("[henitai-debug-child] configure_rspec_runner_return")
|
|
18
|
+
load_rspec_spec_files(test_files)
|
|
19
|
+
run_rspec_specs(runner)
|
|
20
|
+
rescue SystemExit => e
|
|
21
|
+
debug_child_puts("[henitai-debug-child] runner_run_system_exit status=#{e.status.inspect}")
|
|
22
|
+
raise
|
|
23
|
+
ensure
|
|
24
|
+
debug_child_puts("[henitai-debug-child] runner_run_ensure")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def build_rspec_runner
|
|
28
|
+
# @type var empty_args: Array[String]
|
|
29
|
+
empty_args = []
|
|
30
|
+
configuration_options = ::RSpec::Core.const_get(:ConfigurationOptions).new(empty_args)
|
|
31
|
+
::RSpec::Core::Runner.__send__(:new, configuration_options)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def configure_rspec_runner(runner)
|
|
35
|
+
debug_child_puts("[henitai-debug-child] trap_interrupt_start")
|
|
36
|
+
::RSpec::Core::Runner.__send__(:trap_interrupt)
|
|
37
|
+
debug_child_puts("[henitai-debug-child] trap_interrupt_return")
|
|
38
|
+
debug_child_puts("[henitai-debug-child] runner_configure_start")
|
|
39
|
+
runner.send(:configure, $stderr, $stdout)
|
|
40
|
+
debug_child_puts("[henitai-debug-child] runner_configure_return")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def load_rspec_spec_files(test_files)
|
|
44
|
+
debug_child_puts("[henitai-debug-child] load_spec_files_start")
|
|
45
|
+
::RSpec.configuration.files_to_run = test_files.map do |file|
|
|
46
|
+
File.expand_path(file)
|
|
47
|
+
end
|
|
48
|
+
::RSpec.configuration.load_spec_files
|
|
49
|
+
debug_child_example_count("after_load")
|
|
50
|
+
debug_child_puts("[henitai-debug-child] load_spec_files_return")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def run_rspec_specs(runner)
|
|
54
|
+
debug_child_puts("[henitai-debug-child] run_specs_start")
|
|
55
|
+
result = runner.send(:run_specs, ::RSpec.world.ordered_example_groups)
|
|
56
|
+
debug_child_puts("[henitai-debug-child] run_specs_return result=#{result.inspect}")
|
|
57
|
+
result
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
module Integration
|
|
5
|
+
# Spec-file discovery and subject-to-spec matching for the RSpec
|
|
6
|
+
# integration. Selection uses longest-prefix matching against the subject
|
|
7
|
+
# expression and namespace, with a transitive require-based fallback.
|
|
8
|
+
module RspecTestSelection
|
|
9
|
+
REQUIRE_DIRECTIVE_PATTERN = /
|
|
10
|
+
\A\s*
|
|
11
|
+
(require|require_relative)
|
|
12
|
+
\s*
|
|
13
|
+
(?:\(\s*)?
|
|
14
|
+
["']([^"']+)["']
|
|
15
|
+
\s*\)?
|
|
16
|
+
/x
|
|
17
|
+
|
|
18
|
+
def select_tests(subject)
|
|
19
|
+
matches = spec_files.select do |path|
|
|
20
|
+
content = File.read(path)
|
|
21
|
+
# rubocop:disable Style/ArrayIntersect -- content is a String (substring
|
|
22
|
+
# search), not a collection; `intersect?` would raise a TypeError.
|
|
23
|
+
selection_patterns(subject).any? { |pattern| content.include?(pattern) }
|
|
24
|
+
# rubocop:enable Style/ArrayIntersect
|
|
25
|
+
rescue StandardError
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
return matches unless matches.empty?
|
|
30
|
+
|
|
31
|
+
fallback_spec_files(subject)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def spec_files
|
|
37
|
+
@spec_files ||= begin
|
|
38
|
+
paths = Dir.glob("spec/**/*_spec.rb")
|
|
39
|
+
paths - excluded_spec_files
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def fallback_spec_files(subject)
|
|
44
|
+
return [] unless subject.source_file
|
|
45
|
+
|
|
46
|
+
matches = spec_files.select do |path|
|
|
47
|
+
requires_source_file_transitively?(path, subject.source_file)
|
|
48
|
+
rescue StandardError
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
matches.empty? ? spec_files : matches
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def excluded_spec_files
|
|
56
|
+
@excluded_spec_files ||= rspec_exclude_patterns.flat_map { |pattern| Dir.glob(pattern) }.uniq
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def rspec_exclude_patterns
|
|
60
|
+
rspec_config_lines.filter_map do |line|
|
|
61
|
+
line[/\A--exclude-pattern\s+(.+)\z/, 1]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def rspec_config_lines
|
|
66
|
+
return [] unless File.exist?(rspec_config_path)
|
|
67
|
+
|
|
68
|
+
File.readlines(rspec_config_path, chomp: true).map(&:strip)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def rspec_config_path
|
|
72
|
+
".rspec"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def selection_patterns(subject)
|
|
76
|
+
[
|
|
77
|
+
subject.expression,
|
|
78
|
+
subject.namespace
|
|
79
|
+
].compact.uniq.sort_by(&:length).reverse
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def requires_source_file?(spec_file, source_file)
|
|
83
|
+
content = File.read(spec_file)
|
|
84
|
+
basename = File.basename(source_file, ".rb")
|
|
85
|
+
content.include?(basename) || content.include?(source_file)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def requires_source_file_transitively?(spec_file, source_file, visited = [])
|
|
89
|
+
normalized_spec_file = File.expand_path(spec_file)
|
|
90
|
+
return false if visited.include?(normalized_spec_file)
|
|
91
|
+
|
|
92
|
+
visited << normalized_spec_file
|
|
93
|
+
return true if requires_source_file?(spec_file, source_file)
|
|
94
|
+
|
|
95
|
+
required_files(spec_file).any? do |required_file|
|
|
96
|
+
requires_source_file_transitively?(required_file, source_file, visited)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def required_files(spec_file)
|
|
101
|
+
File.read(spec_file).lines.filter_map do |line|
|
|
102
|
+
match = line.match(REQUIRE_DIRECTIVE_PATTERN)
|
|
103
|
+
next unless match
|
|
104
|
+
|
|
105
|
+
resolve_required_file(spec_file, match[1].to_s, match[2].to_s)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def resolve_required_file(spec_file, method_name, required_path)
|
|
110
|
+
candidates =
|
|
111
|
+
if method_name == "require_relative"
|
|
112
|
+
relative_candidates(spec_file, required_path)
|
|
113
|
+
else
|
|
114
|
+
require_candidates(spec_file, required_path)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
candidates.find { |candidate| File.file?(candidate) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def relative_candidates(spec_file, required_path)
|
|
121
|
+
expand_candidates(File.dirname(spec_file), required_path)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def require_candidates(spec_file, required_path)
|
|
125
|
+
([File.dirname(spec_file), Dir.pwd] + $LOAD_PATH).flat_map do |base_path|
|
|
126
|
+
expand_candidates(base_path, required_path)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def expand_candidates(base_path, required_path)
|
|
131
|
+
[
|
|
132
|
+
File.expand_path(required_path, base_path),
|
|
133
|
+
File.expand_path("#{required_path}.rb", base_path)
|
|
134
|
+
].uniq
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
module Integration
|
|
7
|
+
# Shared helpers for capturing stdout/stderr from child test processes and
|
|
8
|
+
# for reading and combining the captured log files afterwards.
|
|
9
|
+
class ScenarioLogSupport
|
|
10
|
+
# Env var carrying the per-stream capture cap (bytes) into forked
|
|
11
|
+
# children. Set by the execution engine from Configuration#max_log_bytes.
|
|
12
|
+
MAX_LOG_BYTES_ENV = "HENITAI_MAX_LOG_BYTES"
|
|
13
|
+
DEFAULT_MAX_LOG_BYTES = 5_000_000
|
|
14
|
+
DRAIN_CHUNK_BYTES = 65_536
|
|
15
|
+
|
|
16
|
+
def capture_child_output(log_paths)
|
|
17
|
+
output_files = open_child_output(log_paths)
|
|
18
|
+
yield
|
|
19
|
+
ensure
|
|
20
|
+
close_child_output(output_files)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def with_coverage_dir(mutant_id)
|
|
24
|
+
original_coverage_dir = ENV.fetch("HENITAI_COVERAGE_DIR", nil)
|
|
25
|
+
ENV["HENITAI_COVERAGE_DIR"] = mutation_coverage_dir(mutant_id)
|
|
26
|
+
yield
|
|
27
|
+
ensure
|
|
28
|
+
if original_coverage_dir.nil?
|
|
29
|
+
ENV.delete("HENITAI_COVERAGE_DIR")
|
|
30
|
+
else
|
|
31
|
+
ENV["HENITAI_COVERAGE_DIR"] = original_coverage_dir
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Captures via a pipe drained by a background thread that writes to the
|
|
36
|
+
# log file only up to +max_log_bytes+ and then discards the overflow.
|
|
37
|
+
# A runaway mutant (e.g. one that recurses henitai into itself and spews
|
|
38
|
+
# backtraces) can otherwise fill hundreds of MB per child; draining past
|
|
39
|
+
# the cap keeps the writer from blocking on a full pipe.
|
|
40
|
+
def open_child_output(log_paths)
|
|
41
|
+
FileUtils.mkdir_p(File.dirname(log_paths[:log_path]))
|
|
42
|
+
cap = max_log_bytes
|
|
43
|
+
output_files = {
|
|
44
|
+
original_stdout: stdout_stream.dup,
|
|
45
|
+
original_stderr: stderr_stream.dup,
|
|
46
|
+
stdout: open_capped_stream(log_paths[:stdout_path], cap),
|
|
47
|
+
stderr: open_capped_stream(log_paths[:stderr_path], cap)
|
|
48
|
+
}
|
|
49
|
+
redirect_child_output(output_files)
|
|
50
|
+
output_files
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def close_child_output(output_files)
|
|
54
|
+
return unless output_files
|
|
55
|
+
|
|
56
|
+
restore_child_output(output_files)
|
|
57
|
+
finish_capped_stream(output_files[:stdout])
|
|
58
|
+
finish_capped_stream(output_files[:stderr])
|
|
59
|
+
output_files[:original_stdout]&.close
|
|
60
|
+
output_files[:original_stderr]&.close
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# One capture channel: a pipe whose read end is drained (capped) into the
|
|
64
|
+
# log file by a dedicated thread. Returns the pieces close needs.
|
|
65
|
+
def open_capped_stream(path, cap)
|
|
66
|
+
file = File.new(path, "w")
|
|
67
|
+
file.sync = true
|
|
68
|
+
reader, writer = IO.pipe
|
|
69
|
+
thread = Thread.new { drain_capped(reader, file, cap) }
|
|
70
|
+
{ file:, reader:, writer:, thread: }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def drain_capped(reader, file, cap)
|
|
74
|
+
written = 0
|
|
75
|
+
truncated = false
|
|
76
|
+
loop do
|
|
77
|
+
chunk = reader.readpartial(DRAIN_CHUNK_BYTES)
|
|
78
|
+
next unless written < cap
|
|
79
|
+
|
|
80
|
+
slice = chunk.byteslice(0, cap - written)
|
|
81
|
+
file.write(slice)
|
|
82
|
+
written += slice.bytesize
|
|
83
|
+
next unless written >= cap && !truncated
|
|
84
|
+
|
|
85
|
+
file.write("\n[henitai] output truncated at #{cap} bytes\n")
|
|
86
|
+
truncated = true
|
|
87
|
+
end
|
|
88
|
+
rescue IOError
|
|
89
|
+
# EOFError (a subclass) on writer close, or a closed pipe: draining done.
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def finish_capped_stream(stream)
|
|
94
|
+
return unless stream
|
|
95
|
+
|
|
96
|
+
stream[:writer].close
|
|
97
|
+
stream[:thread].join
|
|
98
|
+
stream[:reader].close
|
|
99
|
+
stream[:file].close
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def redirect_child_output(output_files)
|
|
103
|
+
reopen_child_output_stream(stdout_stream, output_files[:stdout][:writer])
|
|
104
|
+
reopen_child_output_stream(stderr_stream, output_files[:stderr][:writer])
|
|
105
|
+
$stdout = stdout_stream
|
|
106
|
+
$stderr = stderr_stream
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def restore_child_output(output_files)
|
|
110
|
+
reopen_child_output_stream(stdout_stream, output_files[:original_stdout])
|
|
111
|
+
reopen_child_output_stream(stderr_stream, output_files[:original_stderr])
|
|
112
|
+
$stdout = stdout_stream
|
|
113
|
+
$stderr = stderr_stream
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def reopen_child_output_stream(stream, original_stream)
|
|
117
|
+
stream.reopen(original_stream) if original_stream
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def max_log_bytes
|
|
121
|
+
raw = ENV.fetch(MAX_LOG_BYTES_ENV, nil)
|
|
122
|
+
value = raw.to_i
|
|
123
|
+
value.positive? ? value : DEFAULT_MAX_LOG_BYTES
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def read_log_file(path)
|
|
127
|
+
return "" unless File.exist?(path)
|
|
128
|
+
|
|
129
|
+
File.read(path)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def write_combined_log(path, stdout, stderr)
|
|
133
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
134
|
+
File.write(path, combined_log(stdout, stderr))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def combined_log(stdout, stderr)
|
|
138
|
+
[
|
|
139
|
+
(stdout.empty? ? nil : "stdout:\n#{stdout}"),
|
|
140
|
+
(stderr.empty? ? nil : "stderr:\n#{stderr}")
|
|
141
|
+
].compact.join("\n")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def mutation_coverage_dir(mutant_id)
|
|
147
|
+
reports_dir = ENV.fetch("HENITAI_REPORTS_DIR", "reports")
|
|
148
|
+
File.join(reports_dir, "mutation-coverage", mutant_id.to_s)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def stdout_stream
|
|
152
|
+
@stdout_stream ||= IO.for_fd(1)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def stderr_stream
|
|
156
|
+
@stderr_stream ||= IO.for_fd(2)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|