evilution 0.31.0 → 0.33.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/.beads/interactions.jsonl +28 -0
- data/.rubocop_todo.yml +1 -0
- data/CHANGELOG.md +28 -0
- data/README.md +8 -8
- data/docs/integrations.md +141 -0
- data/docs/isolation.md +15 -0
- data/lib/evilution/baseline.rb +11 -4
- data/lib/evilution/cli/parser/options_builder.rb +1 -1
- data/lib/evilution/config/validators/integration.rb +5 -1
- data/lib/evilution/config.rb +1 -1
- data/lib/evilution/integration/loading/mutation_applier.rb +1 -2
- data/lib/evilution/integration/loading/source_evaluator.rb +1 -2
- data/lib/evilution/integration/loading/test_load_path.rb +76 -0
- data/lib/evilution/integration/minitest.rb +6 -3
- data/lib/evilution/integration/rspec/baseline_runner.rb +3 -1
- data/lib/evilution/integration/rspec/framework_loader.rb +5 -1
- data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +72 -0
- data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +45 -0
- data/lib/evilution/integration/rspec/state_guard.rb +3 -1
- data/lib/evilution/integration/test_unit/dispatcher.rb +26 -0
- data/lib/evilution/integration/test_unit/framework_loader.rb +33 -0
- data/lib/evilution/integration/test_unit/result_builder.rb +53 -0
- data/lib/evilution/integration/test_unit/subject_class_registry.rb +26 -0
- data/lib/evilution/integration/test_unit/test_file_resolver.rb +48 -0
- data/lib/evilution/integration/test_unit.rb +132 -0
- data/lib/evilution/integration/test_unit_crash_detector.rb +61 -0
- data/lib/evilution/isolation/fork.rb +45 -3
- data/lib/evilution/mcp/info_tool.rb +2 -2
- data/lib/evilution/mcp/mutate_tool.rb +3 -2
- data/lib/evilution/parallel/work_queue/dispatcher.rb +94 -22
- data/lib/evilution/parallel/work_queue/worker.rb +49 -3
- data/lib/evilution/parallel/work_queue/worker_registry.rb +47 -0
- data/lib/evilution/parallel/work_queue.rb +8 -0
- data/lib/evilution/reporter/cli/line_formatters/unresolved_rate_warning.rb +50 -0
- data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
- data/lib/evilution/runner/baseline_runner.rb +3 -1
- data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +28 -1
- data/lib/evilution/runner.rb +12 -1
- data/lib/evilution/spec_resolver.rb +81 -9
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +11 -0
- data/lib/tasks/stress.rake +15 -0
- data/script/run_self_baseline +2 -2
- metadata +16 -2
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../rspec"
|
|
4
|
+
|
|
5
|
+
# Restores the RSpec.configuration state that ::RSpec::Core::Runner.run mutates
|
|
6
|
+
# on the shared singleton during an in-process mutation run (EV-dwqw / GH #1343).
|
|
7
|
+
#
|
|
8
|
+
# The visible symptom was every SUBSEQUENT host example rendering its progress
|
|
9
|
+
# dots without color (white instead of green). Root cause: the run's args carry
|
|
10
|
+
# "--no-color", and ConfigurationOptions#configure applies it via
|
|
11
|
+
# Configuration#force, which does `@preferred_options.merge!(:color_mode => :off)`
|
|
12
|
+
# IN PLACE. `Configuration#color_mode` reads `@preferred_options.fetch(:color_mode)`
|
|
13
|
+
# first, so the host's color setting stays :off for the rest of the process.
|
|
14
|
+
# Separately, Runner#setup points `output_stream`/`error_stream` (attr_writers,
|
|
15
|
+
# i.e. the @output_stream/@error_stream ivars) at the run's throwaway StringIOs.
|
|
16
|
+
#
|
|
17
|
+
# Forked runs never leak these (the mutation happens in a child that dies); only
|
|
18
|
+
# the in-process isolation path mutates the host's own configuration.
|
|
19
|
+
#
|
|
20
|
+
# @preferred_options is mutated in place, so it is snapshotted by DUP and put
|
|
21
|
+
# back by replacing the ivar; the stream ivars are reassigned during the run,
|
|
22
|
+
# so capturing the original references is enough.
|
|
23
|
+
class Evilution::Integration::RSpec::StateGuard::ConfigurationState
|
|
24
|
+
PREFERRED_OPTIONS = :@preferred_options
|
|
25
|
+
STREAM_IVARS = %i[@output_stream @error_stream].freeze
|
|
26
|
+
ALL_IVARS = [PREFERRED_OPTIONS, *STREAM_IVARS].freeze
|
|
27
|
+
|
|
28
|
+
# configuration is injectable for isolated unit testing; production uses the
|
|
29
|
+
# shared RSpec.configuration singleton.
|
|
30
|
+
def initialize(configuration: nil)
|
|
31
|
+
@configuration = configuration
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def snapshot
|
|
35
|
+
config = configuration
|
|
36
|
+
state = {}
|
|
37
|
+
capture_preferred_options(config, state)
|
|
38
|
+
STREAM_IVARS.each do |ivar|
|
|
39
|
+
state[ivar] = config.instance_variable_get(ivar) if config.instance_variable_defined?(ivar)
|
|
40
|
+
end
|
|
41
|
+
state
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Restore exactly the pre-run state: ivars present at snapshot get their value
|
|
45
|
+
# back; ivars created by the run but absent before are removed, so nothing the
|
|
46
|
+
# run introduced can leak into the host either.
|
|
47
|
+
def release(captured)
|
|
48
|
+
return unless captured
|
|
49
|
+
|
|
50
|
+
config = configuration
|
|
51
|
+
ALL_IVARS.each do |ivar|
|
|
52
|
+
if captured.key?(ivar)
|
|
53
|
+
config.instance_variable_set(ivar, captured[ivar])
|
|
54
|
+
elsif config.instance_variable_defined?(ivar)
|
|
55
|
+
config.remove_instance_variable(ivar)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def configuration
|
|
63
|
+
@configuration || ::RSpec.configuration
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def capture_preferred_options(config, state)
|
|
67
|
+
return unless config.instance_variable_defined?(PREFERRED_OPTIONS)
|
|
68
|
+
|
|
69
|
+
value = config.instance_variable_get(PREFERRED_OPTIONS)
|
|
70
|
+
state[PREFERRED_OPTIONS] = value.is_a?(Hash) ? value.dup : value
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../rspec"
|
|
4
|
+
require_relative "internals"
|
|
5
|
+
|
|
6
|
+
# Restores the RSpec.configuration fields that ::RSpec::Core::Runner.run mutates
|
|
7
|
+
# on the shared singleton during an in-process mutation run (EV-dwqw / GH #1343):
|
|
8
|
+
#
|
|
9
|
+
# - @color_mode -- the run's args carry "--no-color", flipping it to :off,
|
|
10
|
+
# which makes every SUBSEQUENT host example render its
|
|
11
|
+
# progress dots without color (white instead of green).
|
|
12
|
+
# - @output_stream -- Runner#setup swaps it to the run's StringIO whenever the
|
|
13
|
+
# host's was $stdout.
|
|
14
|
+
# - @error_stream -- likewise pointed at the run's StringIO.
|
|
15
|
+
#
|
|
16
|
+
# Forked runs never leak these (the mutation happens in a child that dies), but
|
|
17
|
+
# the in-process isolation path mutates the host's own configuration. Restore by
|
|
18
|
+
# writing the ivars directly: configuration#output_stream= is guarded (it warns
|
|
19
|
+
# and no-ops once a reporter exists), so the public setter cannot put it back.
|
|
20
|
+
class Evilution::Integration::RSpec::StateGuard::ConfigurationStreams
|
|
21
|
+
IVARS = %i[@color_mode @output_stream @error_stream].freeze
|
|
22
|
+
|
|
23
|
+
def snapshot
|
|
24
|
+
config = ::RSpec.configuration
|
|
25
|
+
IVARS.each_with_object({}) do |ivar, acc|
|
|
26
|
+
acc[ivar] = config.instance_variable_get(ivar) if config.instance_variable_defined?(ivar)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Restore exactly the pre-run state: ivars present at snapshot get their value
|
|
31
|
+
# back; ivars that were absent before but created by the run are removed, so a
|
|
32
|
+
# newly-defined ivar can't leak into the host either.
|
|
33
|
+
def release(captured)
|
|
34
|
+
return unless captured
|
|
35
|
+
|
|
36
|
+
config = ::RSpec.configuration
|
|
37
|
+
IVARS.each do |ivar|
|
|
38
|
+
if captured.key?(ivar)
|
|
39
|
+
config.instance_variable_set(ivar, captured[ivar])
|
|
40
|
+
elsif config.instance_variable_defined?(ivar)
|
|
41
|
+
config.remove_instance_variable(ivar)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -7,6 +7,7 @@ require_relative "state_guard/world_sources_by_path"
|
|
|
7
7
|
require_relative "state_guard/world_filtered_examples"
|
|
8
8
|
require_relative "state_guard/reporter_arrays"
|
|
9
9
|
require_relative "state_guard/example_groups_constants"
|
|
10
|
+
require_relative "state_guard/configuration_state"
|
|
10
11
|
|
|
11
12
|
class Evilution::Integration::RSpec::StateGuard
|
|
12
13
|
DEFAULT_STRATEGIES = [
|
|
@@ -15,7 +16,8 @@ class Evilution::Integration::RSpec::StateGuard
|
|
|
15
16
|
WorldSourcesByPath.new,
|
|
16
17
|
WorldFilteredExamples.new,
|
|
17
18
|
ReporterArrays.new,
|
|
18
|
-
ExampleGroupsConstants.new
|
|
19
|
+
ExampleGroupsConstants.new,
|
|
20
|
+
ConfigurationState.new
|
|
19
21
|
].freeze
|
|
20
22
|
|
|
21
23
|
def initialize(strategies: DEFAULT_STRATEGIES)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require_relative "../test_unit"
|
|
5
|
+
|
|
6
|
+
# Builds a Test::Unit::TestSuite from a list of TestCase subclasses and runs
|
|
7
|
+
# it via the console runner with output captured to a StringIO. Owning this
|
|
8
|
+
# responsibility separately keeps the runner library require + suite assembly
|
|
9
|
+
# in one place — used by both the baseline path (Evilution::Integration::TestUnit
|
|
10
|
+
# .run_baseline_test_file) and the per-mutation path (#run_tests).
|
|
11
|
+
module Evilution::Integration::TestUnit::Dispatcher
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def call(test_case_classes, name: "evilution")
|
|
15
|
+
require "test/unit/ui/console/testrunner"
|
|
16
|
+
suite = ::Test::Unit::TestSuite.new(name)
|
|
17
|
+
test_case_classes.each { |klass| suite << klass.suite }
|
|
18
|
+
out = StringIO.new
|
|
19
|
+
runner = ::Test::Unit::UI::Console::TestRunner.new(suite, output: out)
|
|
20
|
+
runner.start
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def test_method_count(test_case_classes)
|
|
24
|
+
test_case_classes.sum { |klass| klass.suite.tests.length }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_unit"
|
|
4
|
+
|
|
5
|
+
# Loads the test-unit gem and disables its at_exit auto-run handler.
|
|
6
|
+
# Mirrors Evilution::Integration::RSpec::FrameworkLoader's role: framework
|
|
7
|
+
# setup is one responsibility separated from dispatch + result building so
|
|
8
|
+
# integrations can compose it independently in tests.
|
|
9
|
+
class Evilution::Integration::TestUnit::FrameworkLoader
|
|
10
|
+
def loaded?
|
|
11
|
+
@loaded == true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
return if @loaded
|
|
16
|
+
|
|
17
|
+
require "test-unit"
|
|
18
|
+
self.class.stub_autorun!
|
|
19
|
+
@loaded = true
|
|
20
|
+
rescue LoadError => e
|
|
21
|
+
raise Evilution::Error, "test-unit is required but not available: #{e.message}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# User code that `require "test-unit"` installs an at_exit hook that calls
|
|
25
|
+
# Test::Unit::AutoRunner.run when need_auto_run? is true. At evilution exit
|
|
26
|
+
# ARGV still holds evilution flags and the runner prints a misleading banner.
|
|
27
|
+
# Flipping need_auto_run = false prevents the handler from firing.
|
|
28
|
+
def self.stub_autorun!
|
|
29
|
+
return unless defined?(::Test::Unit::AutoRunner)
|
|
30
|
+
|
|
31
|
+
::Test::Unit::AutoRunner.need_auto_run = false
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_unit"
|
|
4
|
+
|
|
5
|
+
# Shapes the result Hash that flows back to Evilution::Result::MutationResult
|
|
6
|
+
# / classify_status. Three orthogonal flavours — pass/fail/crash, no tests
|
|
7
|
+
# executed, and unresolved spec — each have their own change axis (e.g. the
|
|
8
|
+
# no-tests-ran error string evolved separately as the test-unit framework
|
|
9
|
+
# diagnostic improved). Putting them behind a single object documents the
|
|
10
|
+
# contract and lets the integration class drop them.
|
|
11
|
+
module Evilution::Integration::TestUnit::ResultBuilder
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def call(passed:, command:, detector:)
|
|
15
|
+
if passed
|
|
16
|
+
{ passed: true, test_command: command }
|
|
17
|
+
elsif detector.only_crashes?
|
|
18
|
+
crash(command, detector)
|
|
19
|
+
else
|
|
20
|
+
{ passed: false, test_command: command }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def no_tests_ran(command)
|
|
25
|
+
{
|
|
26
|
+
passed: false,
|
|
27
|
+
error: "no Test::Unit tests executed (0 test methods ran) — the resolved " \
|
|
28
|
+
"spec registered no Test::Unit suite. Check --integration/--spec.",
|
|
29
|
+
error_class: "Evilution::Error",
|
|
30
|
+
test_command: command
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def unresolved(mutation_file_path)
|
|
35
|
+
{
|
|
36
|
+
passed: false,
|
|
37
|
+
unresolved: true,
|
|
38
|
+
error: "no matching test resolved for #{mutation_file_path}",
|
|
39
|
+
test_command: "ruby -Itest (skipped: no test resolved for #{mutation_file_path})"
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def crash(command, detector)
|
|
44
|
+
classes = detector.unique_crash_classes
|
|
45
|
+
{
|
|
46
|
+
passed: false,
|
|
47
|
+
test_crashed: true,
|
|
48
|
+
error: "test crashes: #{detector.crash_summary}",
|
|
49
|
+
error_class: (classes.first if classes.length == 1),
|
|
50
|
+
test_command: command
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_unit"
|
|
4
|
+
|
|
5
|
+
# Tracks Test::Unit::TestCase descendants in the host process. Test::Unit has
|
|
6
|
+
# no public registry-clear method analogous to Minitest::Runnable.runnables;
|
|
7
|
+
# the integration scopes each dispatch to classes that appeared during *this*
|
|
8
|
+
# round by diffing the descendant set before and after #load. Keeping that
|
|
9
|
+
# responsibility in its own object makes it cheap to stub in tests and lets
|
|
10
|
+
# the integration's main class read as orchestration.
|
|
11
|
+
module Evilution::Integration::TestUnit::SubjectClassRegistry
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def descendants
|
|
15
|
+
return [] unless defined?(::Test::Unit::TestCase)
|
|
16
|
+
|
|
17
|
+
ObjectSpace.each_object(Class).select { |c| c < ::Test::Unit::TestCase }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Yields, captures the descendant set before/after, and returns the diff.
|
|
21
|
+
def newly_loaded
|
|
22
|
+
before = descendants
|
|
23
|
+
yield
|
|
24
|
+
descendants - before
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_unit"
|
|
4
|
+
|
|
5
|
+
# Resolves the list of test files to load for a given mutation. Encapsulates
|
|
6
|
+
# the explicit-override path, spec-selector lookup, fallback glob, and the
|
|
7
|
+
# warn-once behaviour for unresolved sources. The integration class would
|
|
8
|
+
# otherwise carry both per-instance test resolution state (@test_files,
|
|
9
|
+
# @spec_selector, @warned_files, @fallback_to_full_suite) and dispatch
|
|
10
|
+
# orchestration in the same object; splitting them gives the resolver its
|
|
11
|
+
# own change axis (e.g. adding new resolution heuristics) independent of
|
|
12
|
+
# the runner.
|
|
13
|
+
class Evilution::Integration::TestUnit::TestFileResolver
|
|
14
|
+
def initialize(test_files:, spec_selector:, fallback_to_full_suite:)
|
|
15
|
+
@test_files = test_files
|
|
16
|
+
@spec_selector = spec_selector
|
|
17
|
+
@fallback_to_full_suite = fallback_to_full_suite
|
|
18
|
+
@warned_files = Set.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns the resolved file list, or nil if the source could not be
|
|
22
|
+
# resolved and fallback is disabled.
|
|
23
|
+
def call(mutation_file_path)
|
|
24
|
+
return @test_files if @test_files
|
|
25
|
+
|
|
26
|
+
resolved = Array(@spec_selector.call(mutation_file_path))
|
|
27
|
+
return resolved unless resolved.empty?
|
|
28
|
+
|
|
29
|
+
warn_unresolved(mutation_file_path)
|
|
30
|
+
@fallback_to_full_suite ? glob_test_files : nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def glob_test_files
|
|
36
|
+
files = Dir.glob("test/**/*_test.rb")
|
|
37
|
+
files.empty? ? ["test"] : files
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def warn_unresolved(file_path)
|
|
41
|
+
return if @warned_files.include?(file_path)
|
|
42
|
+
|
|
43
|
+
@warned_files << file_path
|
|
44
|
+
action = @fallback_to_full_suite ? "running full suite" : "marking mutation unresolved"
|
|
45
|
+
warn "[evilution] No matching test found for #{file_path}, #{action}. " \
|
|
46
|
+
"Use --spec to specify the test file."
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "test_unit_crash_detector"
|
|
5
|
+
require_relative "loading/test_load_path"
|
|
6
|
+
require_relative "../spec_resolver"
|
|
7
|
+
require_relative "../spec_selector"
|
|
8
|
+
|
|
9
|
+
require_relative "../integration"
|
|
10
|
+
|
|
11
|
+
# Test::Unit integration. Decomposed under lib/evilution/integration/test_unit/
|
|
12
|
+
# mirroring the RSpec integration's layout. This class is the orchestrator:
|
|
13
|
+
# it wires the framework loader, dispatcher, subject-class registry,
|
|
14
|
+
# test-file resolver, and result builder. The class is registered under
|
|
15
|
+
# Evilution::Runner::INTEGRATIONS[:test_unit] and reachable via the
|
|
16
|
+
# `--integration test-unit` CLI flag.
|
|
17
|
+
class Evilution::Integration::TestUnit < Evilution::Integration::Base
|
|
18
|
+
def self.baseline_runner
|
|
19
|
+
->(test_file) { run_baseline_test_file(test_file) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# SpecResolver tuned for the dominant Test::Unit layout: tests live under
|
|
23
|
+
# test/, named with the _test.rb suffix (the same convention Minitest uses).
|
|
24
|
+
# Rails plugins on the test-unit gem (e.g. kaminari-core) follow this layout.
|
|
25
|
+
# The test/test_<name>.rb prefix-style convention is rare enough in practice
|
|
26
|
+
# that we defer support to a follow-up if a project surfaces needing it.
|
|
27
|
+
def self.spec_resolver
|
|
28
|
+
Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.baseline_options
|
|
32
|
+
{
|
|
33
|
+
runner: baseline_runner,
|
|
34
|
+
spec_resolver: spec_resolver,
|
|
35
|
+
fallback_dir: "test"
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.run_baseline_test_file(test_file)
|
|
40
|
+
require_relative "test_unit/framework_loader"
|
|
41
|
+
require_relative "test_unit/subject_class_registry"
|
|
42
|
+
require_relative "test_unit/dispatcher"
|
|
43
|
+
FrameworkLoader.new.call
|
|
44
|
+
files = baseline_test_files(test_file)
|
|
45
|
+
Evilution::Integration::Loading::TestLoadPath.add!(files)
|
|
46
|
+
new_classes = SubjectClassRegistry.newly_loaded do
|
|
47
|
+
files.each { |f| load(File.expand_path(f)) }
|
|
48
|
+
end
|
|
49
|
+
Dispatcher.call(new_classes, name: "evilution baseline").passed?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.baseline_test_files(test_file)
|
|
53
|
+
File.directory?(test_file) ? Dir.glob(File.join(test_file, "**/*_test.rb")) : [test_file]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def initialize(test_files: nil, hooks: nil, fallback_to_full_suite: false, spec_selector: nil)
|
|
57
|
+
require_relative "test_unit/framework_loader"
|
|
58
|
+
require_relative "test_unit/subject_class_registry"
|
|
59
|
+
require_relative "test_unit/dispatcher"
|
|
60
|
+
require_relative "test_unit/test_file_resolver"
|
|
61
|
+
require_relative "test_unit/result_builder"
|
|
62
|
+
@framework_loader = FrameworkLoader.new
|
|
63
|
+
@file_resolver = TestFileResolver.new(
|
|
64
|
+
test_files: test_files,
|
|
65
|
+
spec_selector: spec_selector || Evilution::SpecSelector.new(spec_resolver: self.class.spec_resolver),
|
|
66
|
+
fallback_to_full_suite: fallback_to_full_suite
|
|
67
|
+
)
|
|
68
|
+
@crash_detector = nil
|
|
69
|
+
super(hooks: hooks)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def ensure_framework_loaded
|
|
75
|
+
return if @framework_loader.loaded?
|
|
76
|
+
|
|
77
|
+
fire_hook(:setup_integration_pre, integration: :test_unit)
|
|
78
|
+
@framework_loader.call
|
|
79
|
+
fire_hook(:setup_integration_post, integration: :test_unit)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def run_tests(mutation)
|
|
83
|
+
ensure_framework_loaded
|
|
84
|
+
reset_state
|
|
85
|
+
files = @file_resolver.call(mutation.file_path)
|
|
86
|
+
return ResultBuilder.unresolved(mutation.file_path) if files.nil?
|
|
87
|
+
|
|
88
|
+
command = "ruby -Itest #{files.join(" ")}"
|
|
89
|
+
execute_test_unit(files, command)
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
{ passed: false, error: e.message, test_command: command }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def execute_test_unit(files, command)
|
|
95
|
+
new_classes = load_test_classes(files)
|
|
96
|
+
return ResultBuilder.no_tests_ran(command) if Dispatcher.test_method_count(new_classes).zero?
|
|
97
|
+
|
|
98
|
+
detector = reset_crash_detector
|
|
99
|
+
result = Dispatcher.call(new_classes, name: "evilution-mutation")
|
|
100
|
+
result.faults.each { |fault| detector.record(fault) }
|
|
101
|
+
ResultBuilder.call(passed: result.passed?, command: command, detector: detector)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def load_test_classes(files)
|
|
105
|
+
Evilution::Integration::Loading::TestLoadPath.add!(files)
|
|
106
|
+
SubjectClassRegistry.newly_loaded do
|
|
107
|
+
files.each { |f| load(File.expand_path(f, Evilution.project_base_dir)) }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Test::Unit has no public registry-clear analogous to
|
|
112
|
+
# Minitest::Runnable.runnables.clear. SubjectClassRegistry's newly_loaded
|
|
113
|
+
# block scopes each dispatch to classes loaded in *this* round, so stale
|
|
114
|
+
# classes from prior mutations sit dormant on ObjectSpace without polluting
|
|
115
|
+
# the run. #reset_state stays as a contract no-op for parity with Minitest.
|
|
116
|
+
def reset_state
|
|
117
|
+
# no-op — see comment above
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def build_args(_mutation)
|
|
121
|
+
[]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def reset_crash_detector
|
|
125
|
+
if @crash_detector
|
|
126
|
+
@crash_detector.reset
|
|
127
|
+
else
|
|
128
|
+
@crash_detector = Evilution::Integration::TestUnitCrashDetector.new
|
|
129
|
+
end
|
|
130
|
+
@crash_detector
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../integration"
|
|
4
|
+
|
|
5
|
+
# Test::Unit analog of Evilution::Integration::MinitestCrashDetector. Tracks
|
|
6
|
+
# whether a Test::Unit test run produced only crashes (exceptions captured as
|
|
7
|
+
# Test::Unit::Error) vs assertion failures (Test::Unit::Failure). When only
|
|
8
|
+
# crashes occur, the mutation result can carry crash details while remaining
|
|
9
|
+
# classified as :killed — see classify_status / Result::MutationResult.
|
|
10
|
+
#
|
|
11
|
+
# Hook the detector into a Test::Unit::TestResult via .attach(result), or
|
|
12
|
+
# call #record(fault) directly when iterating a finished result's #faults.
|
|
13
|
+
class Evilution::Integration::TestUnitCrashDetector
|
|
14
|
+
def initialize
|
|
15
|
+
reset
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def reset
|
|
19
|
+
@assertion_failures = 0
|
|
20
|
+
@crashes = []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def attach(test_result)
|
|
24
|
+
require "test/unit/testresult"
|
|
25
|
+
test_result.add_listener(Test::Unit::TestResult::FAULT) { |fault| record(fault) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def record(fault)
|
|
29
|
+
if fault.is_a?(Test::Unit::Error)
|
|
30
|
+
@crashes << fault.exception
|
|
31
|
+
elsif fault.is_a?(Test::Unit::Failure)
|
|
32
|
+
@assertion_failures += 1
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def passed?
|
|
37
|
+
@assertion_failures.zero? && @crashes.empty?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def assertion_failure?
|
|
41
|
+
@assertion_failures.positive?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def crashed?
|
|
45
|
+
@crashes.any?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def only_crashes?
|
|
49
|
+
@crashes.any? && @assertion_failures.zero?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def unique_crash_classes
|
|
53
|
+
@crashes.map { |e| e.class.name }.uniq
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def crash_summary
|
|
57
|
+
return nil if @crashes.empty?
|
|
58
|
+
|
|
59
|
+
"#{unique_crash_classes.join(", ")} (#{@crashes.length} crash#{"es" unless @crashes.length == 1})"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -11,6 +11,7 @@ require_relative "../isolation"
|
|
|
11
11
|
|
|
12
12
|
class Evilution::Isolation::Fork
|
|
13
13
|
GRACE_PERIOD = 2
|
|
14
|
+
REAP_DEADLINE = 1.0
|
|
14
15
|
|
|
15
16
|
def initialize(hooks: nil)
|
|
16
17
|
@hooks = hooks
|
|
@@ -47,6 +48,7 @@ class Evilution::Isolation::Fork
|
|
|
47
48
|
|
|
48
49
|
def fork_child(read_io, write_io, sandbox_dir, mutation, test_command)
|
|
49
50
|
::Process.fork do
|
|
51
|
+
isolate_into_own_process_group
|
|
50
52
|
ENV["TMPDIR"] = sandbox_dir
|
|
51
53
|
# Path-relativizing mutations (e.g. File.join(dir, name) -> name) would
|
|
52
54
|
# otherwise write into the parent's CWD (typically the repo root) and
|
|
@@ -69,6 +71,21 @@ class Evilution::Isolation::Fork
|
|
|
69
71
|
end
|
|
70
72
|
end
|
|
71
73
|
|
|
74
|
+
# EV-2sh8 / GH #1330: make the mutation child its own process-group leader as
|
|
75
|
+
# its very first act, before it runs test_command (which may fork blocking
|
|
76
|
+
# grandchildren -- e.g. connection_pool / ractor / thread subject specs).
|
|
77
|
+
# Grandchildren then inherit this group, so terminate_child can group-kill the
|
|
78
|
+
# whole subtree on timeout. Without it, a blocking grandchild orphans to init
|
|
79
|
+
# and survives the rest of the run -- the inner path never SIGKILLs the worker,
|
|
80
|
+
# so EV-cnx8's outer process-group kill never sweeps it. Done child-side (not
|
|
81
|
+
# parent-side as in Worker) because the per-mutation timeout fires seconds
|
|
82
|
+
# later, long after this line has run, so no fork-before-setpgid race exists.
|
|
83
|
+
def isolate_into_own_process_group
|
|
84
|
+
::Process.setpgid(0, 0)
|
|
85
|
+
rescue SystemCallError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
72
89
|
def cleanup_resources(read_io, write_io, pid, sandbox_dir)
|
|
73
90
|
read_io.close unless read_io.nil?
|
|
74
91
|
write_io.close unless write_io.nil?
|
|
@@ -130,8 +147,23 @@ class Evilution::Isolation::Fork
|
|
|
130
147
|
end
|
|
131
148
|
end
|
|
132
149
|
|
|
150
|
+
# Process.wait without a deadline can hang the parent indefinitely. The
|
|
151
|
+
# per-mutation child may already have written a payload (or a grandchild may
|
|
152
|
+
# have written garbage that looks like one) while the child itself is stuck
|
|
153
|
+
# in execute_in_child waiting on a subject grandchild the mutation broke.
|
|
154
|
+
# wait_for_result has already returned by this point, so the per-mutation
|
|
155
|
+
# timeout cannot fire. Bound the wait and fall back to the TERM/KILL ladder.
|
|
133
156
|
def reap_and_decode(pid, payload)
|
|
134
|
-
|
|
157
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + REAP_DEADLINE
|
|
158
|
+
loop do
|
|
159
|
+
break if ::Process.waitpid(pid, ::Process::WNOHANG)
|
|
160
|
+
|
|
161
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
162
|
+
terminate_child(pid)
|
|
163
|
+
break
|
|
164
|
+
end
|
|
165
|
+
sleep 0.05
|
|
166
|
+
end
|
|
135
167
|
decode_payload(payload)
|
|
136
168
|
end
|
|
137
169
|
|
|
@@ -200,7 +232,7 @@ class Evilution::Isolation::Fork
|
|
|
200
232
|
end
|
|
201
233
|
|
|
202
234
|
def terminate_child(pid)
|
|
203
|
-
|
|
235
|
+
signal_tree("TERM", pid)
|
|
204
236
|
_, status = ::Process.waitpid2(pid, ::Process::WNOHANG)
|
|
205
237
|
return if status
|
|
206
238
|
|
|
@@ -208,10 +240,20 @@ class Evilution::Isolation::Fork
|
|
|
208
240
|
_, status = ::Process.waitpid2(pid, ::Process::WNOHANG)
|
|
209
241
|
return if status
|
|
210
242
|
|
|
211
|
-
|
|
243
|
+
signal_tree("KILL", pid)
|
|
212
244
|
Evilution::ProcessCleanup.safe_wait(pid)
|
|
213
245
|
end
|
|
214
246
|
|
|
247
|
+
# Signal the child's whole process group (-pid) to sweep any grandchildren it
|
|
248
|
+
# forked, then the bare pid as a fallback for the case where setpgid failed
|
|
249
|
+
# (no group exists, so the group signal is a harmless Errno::ESRCH). Only the
|
|
250
|
+
# leader pid is reaped here -- group-killed grandchildren are not our direct
|
|
251
|
+
# children, so init reaps them once they die.
|
|
252
|
+
def signal_tree(sig, pid)
|
|
253
|
+
Evilution::ProcessCleanup.safe_kill(sig, -pid)
|
|
254
|
+
Evilution::ProcessCleanup.safe_kill(sig, pid)
|
|
255
|
+
end
|
|
256
|
+
|
|
215
257
|
def classify_status(result)
|
|
216
258
|
return :timeout if result[:timeout]
|
|
217
259
|
return :killed if result[:test_crashed]
|
|
@@ -58,8 +58,8 @@ class Evilution::MCP::InfoTool < MCP::Tool
|
|
|
58
58
|
},
|
|
59
59
|
integration: {
|
|
60
60
|
type: "string",
|
|
61
|
-
description: "[subjects, tests] Test integration (rspec, minitest) — 'tests' selects " \
|
|
62
|
-
"the matching spec resolver (spec/*_spec.rb for rspec, test/*_test.rb for minitest)"
|
|
61
|
+
description: "[subjects, tests] Test integration (rspec, minitest, test-unit) — 'tests' selects " \
|
|
62
|
+
"the matching spec resolver (spec/*_spec.rb for rspec, test/*_test.rb for minitest/test-unit)"
|
|
63
63
|
},
|
|
64
64
|
skip_config: {
|
|
65
65
|
type: "boolean",
|
|
@@ -72,8 +72,9 @@ class Evilution::MCP::MutateTool < MCP::Tool
|
|
|
72
72
|
},
|
|
73
73
|
integration: {
|
|
74
74
|
type: "string",
|
|
75
|
-
enum: %w[rspec minitest],
|
|
76
|
-
description: "Test integration to use (default: rspec)"
|
|
75
|
+
enum: %w[rspec minitest test-unit],
|
|
76
|
+
description: "Test integration to use (default: rspec). " \
|
|
77
|
+
"Use test-unit for projects whose suites run on the test-unit gem."
|
|
77
78
|
},
|
|
78
79
|
isolation: {
|
|
79
80
|
type: "string",
|