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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +296 -1
- data/README.md +109 -16
- data/assets/schema/henitai.schema.json +20 -0
- data/lib/henitai/arid_node_filter.rb +4 -19
- data/lib/henitai/bootstrap_error_message.rb +20 -0
- data/lib/henitai/canonical_report_merger.rb +23 -2
- data/lib/henitai/cli/run_command.rb +25 -7
- data/lib/henitai/cli/run_options.rb +1 -1
- data/lib/henitai/cli.rb +4 -1
- data/lib/henitai/configuration.rb +4 -1
- data/lib/henitai/configuration_validator/rules.rb +8 -0
- data/lib/henitai/configuration_validator/scalars.rb +9 -0
- data/lib/henitai/configuration_validator.rb +2 -1
- data/lib/henitai/constant_path.rb +48 -0
- data/lib/henitai/control_run.rb +73 -0
- data/lib/henitai/coverage_bootstrapper.rb +57 -15
- data/lib/henitai/coverage_formatter.rb +26 -3
- data/lib/henitai/coverage_report_reader.rb +21 -0
- data/lib/henitai/equivalence_detector/operand_predicates.rb +16 -0
- data/lib/henitai/equivalence_detector.rb +46 -38
- data/lib/henitai/example_summary.rb +24 -0
- data/lib/henitai/execution_engine/fork_server_scope.rb +57 -0
- data/lib/henitai/execution_engine.rb +75 -60
- data/lib/henitai/fidelity_check.rb +173 -0
- data/lib/henitai/flaky_detection_baseline.rb +61 -0
- data/lib/henitai/flaky_mutant_retrier.rb +43 -0
- data/lib/henitai/flaky_retry_decision.rb +23 -0
- data/lib/henitai/flaky_test_registry.rb +63 -0
- data/lib/henitai/fork_server/client.rb +231 -0
- data/lib/henitai/fork_server/group_killer.rb +48 -0
- data/lib/henitai/fork_server/group_terminator.rb +47 -0
- data/lib/henitai/fork_server/launcher.rb +38 -0
- data/lib/henitai/fork_server/mutant_runner.rb +141 -0
- data/lib/henitai/fork_server/protocol.rb +73 -0
- data/lib/henitai/fork_server/reaper.rb +24 -0
- data/lib/henitai/fork_server/runtime.rb +109 -0
- data/lib/henitai/fork_server/server.rb +108 -0
- data/lib/henitai/fork_server/server_process.rb +73 -0
- data/lib/henitai/fork_server/status.rb +52 -0
- data/lib/henitai/fork_server.rb +43 -0
- data/lib/henitai/generated_artifacts.rb +3 -0
- data/lib/henitai/git_diff_analyzer.rb +28 -11
- data/lib/henitai/incremental_filter.rb +20 -10
- data/lib/henitai/integration/base.rb +72 -5
- data/lib/henitai/integration/capped_log_sink.rb +34 -0
- data/lib/henitai/integration/child_bootstrap.rb +7 -0
- data/lib/henitai/integration/child_report_store.rb +36 -0
- data/lib/henitai/integration/minitest.rb +17 -7
- data/lib/henitai/integration/minitest_autorun.rb +20 -0
- data/lib/henitai/integration/minitest_outcome_reporter.rb +28 -0
- data/lib/henitai/integration/minitest_preload.rb +30 -0
- data/lib/henitai/integration/minitest_runnable_filter.rb +61 -0
- data/lib/henitai/integration/minitest_test_runner.rb +32 -15
- data/lib/henitai/integration/mutant_run_support.rb +73 -13
- data/lib/henitai/integration/rspec_child_runner.rb +44 -9
- data/lib/henitai/integration/rspec_preload.rb +35 -0
- data/lib/henitai/integration/rspec_process_runner.rb +4 -1
- data/lib/henitai/integration/scenario_log_support.rb +31 -18
- data/lib/henitai/integration.rb +16 -3
- data/lib/henitai/minitest_coverage_reporter.rb +22 -6
- data/lib/henitai/minitest_result_file.rb +62 -0
- data/lib/henitai/mutant/activation.rb +32 -0
- data/lib/henitai/mutant/activator.rb +75 -55
- data/lib/henitai/mutant/def_injection.rb +136 -0
- data/lib/henitai/mutant/lexical_scope_check.rb +120 -0
- data/lib/henitai/mutant/lexical_target.rb +105 -0
- data/lib/henitai/mutant/replacement.rb +52 -0
- data/lib/henitai/mutant.rb +27 -8
- data/lib/henitai/mutant_generator.rb +62 -18
- data/lib/henitai/mutant_history_store/verdict_cache.rb +12 -11
- data/lib/henitai/mutant_history_store.rb +14 -4
- data/lib/henitai/mutant_identity.rb +5 -1
- data/lib/henitai/mutant_verdict.rb +71 -0
- data/lib/henitai/operators/array_declaration.rb +4 -0
- data/lib/henitai/operators/assignment_expression.rb +2 -0
- data/lib/henitai/operators/conditional_expression.rb +21 -1
- data/lib/henitai/operators/hash_literal.rb +7 -4
- data/lib/henitai/operators/method_expression.rb +14 -3
- data/lib/henitai/operators/regex_metacharacters.rb +89 -0
- data/lib/henitai/operators/regex_mutator.rb +23 -28
- data/lib/henitai/operators/return_value.rb +1 -1
- data/lib/henitai/operators.rb +1 -0
- data/lib/henitai/per_test_coverage.rb +11 -7
- data/lib/henitai/per_test_coverage_collector.rb +30 -31
- data/lib/henitai/per_test_coverage_delta.rb +60 -0
- data/lib/henitai/per_test_coverage_selector.rb +26 -13
- data/lib/henitai/process_wakeup.rb +8 -3
- data/lib/henitai/process_worker_runner.rb +19 -5
- data/lib/henitai/reporter.rb +15 -8
- data/lib/henitai/result.rb +32 -16
- data/lib/henitai/reuse_context.rb +50 -0
- data/lib/henitai/rspec_coverage_formatter.rb +4 -0
- data/lib/henitai/rspec_outcomes.rb +72 -0
- data/lib/henitai/runner.rb +58 -19
- data/lib/henitai/runner_dependencies.rb +22 -1
- data/lib/henitai/sampling_strategy.rb +36 -6
- data/lib/henitai/scenario_execution_result.rb +28 -5
- data/lib/henitai/signature_line.rb +21 -0
- data/lib/henitai/slot_scheduler/draining.rb +12 -2
- data/lib/henitai/slot_scheduler/process_control.rb +33 -0
- data/lib/henitai/slot_scheduler/retry_policy.rb +8 -6
- data/lib/henitai/slot_scheduler.rb +47 -8
- data/lib/henitai/source_file_selection.rb +1 -1
- data/lib/henitai/source_parser.rb +29 -9
- data/lib/henitai/static_filter.rb +45 -14
- data/lib/henitai/stillborn_filter.rb +70 -2
- data/lib/henitai/subject.rb +8 -3
- data/lib/henitai/subject_resolver.rb +65 -31
- data/lib/henitai/suite_boot_clock.rb +48 -0
- data/lib/henitai/survivor_activation_cache.rb +18 -11
- data/lib/henitai/survivor_rerun_strategy.rb +47 -43
- data/lib/henitai/test_file_outcomes.rb +79 -0
- data/lib/henitai/timeout_calibrator.rb +31 -9
- data/lib/henitai/timeout_ceiling.rb +37 -0
- data/lib/henitai/verdict_fingerprint.rb +29 -30
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +17 -1
- data/sig/configuration_validator.rbs +1 -0
- data/sig/henitai.rbs +227 -45
- metadata +46 -3
- 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 =
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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)
|
data/lib/henitai/integration.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
24
|
+
@collector.test_started
|
|
17
25
|
end
|
|
18
26
|
|
|
19
27
|
def record(result)
|
|
20
28
|
super
|
|
21
|
-
|
|
22
|
-
|
|
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
|