evilution 1.3.0 → 1.4.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 +62 -0
- data/.rubocop_todo.yml +5 -0
- data/CHANGELOG.md +77 -0
- data/README.md +80 -24
- data/docs/architecture.md +89 -9
- data/docs/ast_pattern_syntax.md +50 -5
- data/docs/isolation.md +39 -2
- data/docs/migration-from-mutant.md +1 -1
- data/lib/evilution/ast/aasm_declaration.rb +133 -0
- data/lib/evilution/ast/callback_declaration.rb +91 -0
- data/lib/evilution/ast/included_block.rb +27 -0
- data/lib/evilution/ast/literal_callable.rb +21 -0
- data/lib/evilution/ast/parser.rb +119 -16
- data/lib/evilution/ast/pattern/method_name.rb +63 -0
- data/lib/evilution/ast/pattern/parser.rb +14 -15
- data/lib/evilution/ast/regexp_pattern.rb +104 -0
- data/lib/evilution/ast/scope_declaration.rb +45 -0
- data/lib/evilution/ast/uncovered_code.rb +88 -0
- data/lib/evilution/ast/value_object_definition.rb +25 -0
- data/lib/evilution/baseline/failure_formatter.rb +27 -0
- data/lib/evilution/baseline/report.rb +66 -0
- data/lib/evilution/baseline/spec_failure.rb +38 -0
- data/lib/evilution/baseline.rb +67 -30
- data/lib/evilution/cli/parser/file_args.rb +2 -1
- data/lib/evilution/cli/parser/options_builder.rb +1 -1
- data/lib/evilution/cli.rb +3 -2
- data/lib/evilution/config/validators/spec_mappings.rb +2 -1
- data/lib/evilution/config.rb +3 -2
- data/lib/evilution/equivalent/detector.rb +3 -1
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
- data/lib/evilution/example_filter.rb +45 -0
- data/lib/evilution/hooks/registry.rb +2 -1
- data/lib/evilution/integration/base.rb +2 -0
- data/lib/evilution/integration/known_failures.rb +26 -0
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +132 -13
- data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
- data/lib/evilution/integration/loading/concern_redeclaration.rb +61 -0
- data/lib/evilution/integration/loading/concern_state_cleaner.rb +19 -12
- data/lib/evilution/integration/loading/mutation_applier.rb +16 -0
- data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
- data/lib/evilution/integration/minitest/test_ids.rb +21 -0
- data/lib/evilution/integration/minitest.rb +45 -6
- data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
- data/lib/evilution/integration/rspec/example_ids.rb +35 -0
- data/lib/evilution/integration/rspec/result_builder.rb +6 -1
- data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
- data/lib/evilution/integration/rspec/state_guard.rb +4 -1
- data/lib/evilution/integration/rspec.rb +22 -4
- data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
- data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
- data/lib/evilution/integration/test_unit.rb +37 -9
- data/lib/evilution/isolation/fork.rb +2 -1
- data/lib/evilution/isolation/in_process.rb +21 -4
- data/lib/evilution/mcp/info_tool/status_glossary.rb +2 -2
- data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
- data/lib/evilution/memory/leak_check.rb +27 -3
- data/lib/evilution/mutation.rb +7 -2
- data/lib/evilution/mutator/base.rb +49 -5
- data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
- data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
- data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
- data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
- data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
- data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
- data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
- data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
- data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
- data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
- data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
- data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
- data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
- data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
- data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
- data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
- data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
- data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
- data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
- data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
- data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
- data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
- data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
- data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
- data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
- data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
- data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
- data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
- data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
- data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
- data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
- data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
- data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
- data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
- data/lib/evilution/mutator/registry.rb +31 -2
- data/lib/evilution/mutator/rescue_handlers.rb +81 -0
- data/lib/evilution/process_supervisor.rb +3 -2
- data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
- data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
- data/lib/evilution/reporter/json/baseline.rb +15 -0
- data/lib/evilution/reporter/json.rb +7 -2
- data/lib/evilution/result/baseline_neutralization.rb +10 -0
- data/lib/evilution/result/mutation_result.rb +9 -1
- data/lib/evilution/result/summary.rb +42 -2
- data/lib/evilution/runner/baseline_runner.rb +9 -4
- data/lib/evilution/runner/canary.rb +2 -57
- data/lib/evilution/runner/canary_failure_message.rb +101 -0
- data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
- data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
- data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
- data/lib/evilution/runner/mutation_executor.rb +1 -1
- data/lib/evilution/runner/mutation_planner.rb +11 -2
- data/lib/evilution/runner/report_publisher.rb +3 -2
- data/lib/evilution/runner/subject_pipeline.rb +46 -1
- data/lib/evilution/runner.rb +4 -1
- data/lib/evilution/subject.rb +8 -2
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +28 -0
- data/script/memory_check +62 -16
- metadata +83 -4
|
@@ -13,19 +13,23 @@ class Evilution::Integration::Loading::ConcernStateCleaner
|
|
|
13
13
|
|
|
14
14
|
# ObjectSpace hands us every Module in the VM, including ones that answer no
|
|
15
15
|
# message honestly. ActiveSupport::Deprecation::DeprecatedConstantProxy
|
|
16
|
-
# subclasses Module and undefines all of its instance methods, so
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
# for any method_missing-based wrapper, not just ActiveSupport's.
|
|
16
|
+
# subclasses Module and undefines all of its instance methods, so asking it
|
|
17
|
+
# anything lands in its method_missing and emits the deprecation; where the
|
|
18
|
+
# deprecator's behaviour is :raise (grape's spec_helper sets that), the
|
|
19
|
+
# sweep blows up on a module it merely walked past. So the module is never
|
|
20
|
+
# asked: `Concern === mod` is answered by Concern, and the ivars are reached
|
|
21
|
+
# through UnboundMethods, which bypass the object's own method table -- this
|
|
22
|
+
# holds for any method_missing-based wrapper, not just ActiveSupport's.
|
|
23
23
|
# EV-v2rc / GH #1581.
|
|
24
|
-
|
|
24
|
+
#
|
|
25
|
+
# `===` also leaves the module as it found it. Asking for its
|
|
26
|
+
# singleton_class instead would create one for every module in the VM, each
|
|
27
|
+
# of them a module the next sweep has to visit and give a singleton class
|
|
28
|
+
# of its own; a long in-process run doubles its module count per mutation.
|
|
25
29
|
IVAR_DEFINED = Object.instance_method(:instance_variable_defined?)
|
|
26
30
|
IVAR_GET = Object.instance_method(:instance_variable_get)
|
|
27
31
|
REMOVE_IVAR = Object.instance_method(:remove_instance_variable)
|
|
28
|
-
private_constant :
|
|
32
|
+
private_constant :IVAR_DEFINED, :IVAR_GET, :REMOVE_IVAR
|
|
29
33
|
|
|
30
34
|
def initialize(subpath_resolver: Evilution::LoadPath::SubpathResolver.new)
|
|
31
35
|
@subpath_resolver = subpath_resolver
|
|
@@ -34,11 +38,14 @@ class Evilution::Integration::Loading::ConcernStateCleaner
|
|
|
34
38
|
def call(file_path)
|
|
35
39
|
return unless defined?(ActiveSupport::Concern)
|
|
36
40
|
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
# Anchored on the project, not Dir.pwd: an isolated worker has chdir'd
|
|
42
|
+
# into a per-mutation sandbox, where a relative path names nothing and no
|
|
43
|
+
# concern would ever match.
|
|
44
|
+
absolute = File.expand_path(file_path, Evilution.project_base_dir)
|
|
45
|
+
subpath = @subpath_resolver.call(absolute)
|
|
39
46
|
|
|
40
47
|
ObjectSpace.each_object(Module) do |mod|
|
|
41
|
-
next unless
|
|
48
|
+
next unless ActiveSupport::Concern === mod # rubocop:disable Style/CaseEquality
|
|
42
49
|
|
|
43
50
|
clear_concern_ivars(mod, absolute, subpath)
|
|
44
51
|
end
|
|
@@ -6,6 +6,7 @@ require_relative "constant_pinner"
|
|
|
6
6
|
require_relative "concern_state_cleaner"
|
|
7
7
|
require_relative "source_evaluator"
|
|
8
8
|
require_relative "redefinition_recovery"
|
|
9
|
+
require_relative "../../diagnostic"
|
|
9
10
|
|
|
10
11
|
# Composes the load-time pipeline that applies a mutation's new source to the
|
|
11
12
|
# running VM: syntax-validate -> pin top-level constants (beats Zeitwerk) ->
|
|
@@ -45,6 +46,21 @@ class Evilution::Integration::Loading::MutationApplier
|
|
|
45
46
|
failure_result(e, "#{e.class}: #{e.message}")
|
|
46
47
|
end
|
|
47
48
|
|
|
49
|
+
# Evaluates the mutation's restore_source, if it has one. Runs after the
|
|
50
|
+
# tests: a failure here must not change the mutation's result, so it is
|
|
51
|
+
# reported and swallowed. Concern state is cleared first, as it is before
|
|
52
|
+
# applying: a concern ignores an `included` block handed to it twice from
|
|
53
|
+
# the same place, and would keep the mutated one.
|
|
54
|
+
def restore(mutation)
|
|
55
|
+
source = mutation.respond_to?(:restore_source) ? mutation.restore_source : nil
|
|
56
|
+
return unless source
|
|
57
|
+
|
|
58
|
+
@concern_state_cleaner.call(mutation.file_path)
|
|
59
|
+
@source_evaluator.call(source, mutation.file_path)
|
|
60
|
+
rescue ScriptError, StandardError => e
|
|
61
|
+
Evilution::Diagnostic.warn("[evilution] could not restore #{mutation.file_path}: #{e.class}: #{e.message}")
|
|
62
|
+
end
|
|
63
|
+
|
|
48
64
|
private
|
|
49
65
|
|
|
50
66
|
def resolve_eval_target(mutation)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../loading"
|
|
4
|
+
|
|
5
|
+
# Remembers which test classes each test file registered when it was loaded,
|
|
6
|
+
# so a process that runs many mutations loads a test file once.
|
|
7
|
+
#
|
|
8
|
+
# Minitest and Test::Unit learn of a test class when it is first defined.
|
|
9
|
+
# Loading its file again reopens the class and registers nothing, so the
|
|
10
|
+
# second mutation run in a process would find no tests. The classes of the
|
|
11
|
+
# first load are handed back instead.
|
|
12
|
+
#
|
|
13
|
+
# The block given to .new returns the classes registered so far; what a load
|
|
14
|
+
# adds to them is taken to be that file's.
|
|
15
|
+
class Evilution::Integration::Loading::TestClassCache
|
|
16
|
+
def initialize(®istered)
|
|
17
|
+
@registered = registered
|
|
18
|
+
@classes = {}
|
|
19
|
+
@partial = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The classes the given files registered. Yields each file that has not
|
|
23
|
+
# been loaded yet; the block loads it.
|
|
24
|
+
def fetch(files, &)
|
|
25
|
+
files.flat_map { |file| @classes[file] ||= load_once(file, &) }.uniq
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def clear
|
|
29
|
+
@classes.clear
|
|
30
|
+
@partial.clear
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# A load that raises is tried again next time. Classes it defined before
|
|
36
|
+
# raising are already registered and will not register again, so they are
|
|
37
|
+
# kept for that next attempt.
|
|
38
|
+
def load_once(file)
|
|
39
|
+
before = @registered.call
|
|
40
|
+
yield file
|
|
41
|
+
@partial.fetch(file, []) | (@registered.call - before)
|
|
42
|
+
rescue ScriptError, StandardError
|
|
43
|
+
@partial[file] = @partial.fetch(file, []) | (@registered.call - before)
|
|
44
|
+
raise
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../minitest"
|
|
4
|
+
|
|
5
|
+
# Names a Minitest test the same way in the baseline and in a mutation run:
|
|
6
|
+
# its class and its method, `CalcTest#test_adds`.
|
|
7
|
+
#
|
|
8
|
+
# A test class without a name has only its method to go by, so two such
|
|
9
|
+
# classes with a method of the same name are told apart by neither run.
|
|
10
|
+
module Evilution::Integration::Minitest::TestIds
|
|
11
|
+
def self.of(result)
|
|
12
|
+
class_name = result.respond_to?(:class_name) ? result.class_name : result.class.name
|
|
13
|
+
"#{class_name}##{result.name}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The ids of the tests that failed, from the summary reporter of the run.
|
|
17
|
+
# It keeps every result that did not pass, skips included.
|
|
18
|
+
def self.failed(summary)
|
|
19
|
+
summary.results.reject(&:skipped?).map { |result| of(result) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -4,9 +4,12 @@ require "stringio"
|
|
|
4
4
|
require_relative "base"
|
|
5
5
|
require_relative "../diagnostic"
|
|
6
6
|
require_relative "minitest_crash_detector"
|
|
7
|
+
require_relative "known_failures"
|
|
7
8
|
require_relative "loading/test_load_path"
|
|
9
|
+
require_relative "loading/test_class_cache"
|
|
8
10
|
require_relative "../spec_resolver"
|
|
9
11
|
require_relative "../spec_selector"
|
|
12
|
+
require_relative "../baseline"
|
|
10
13
|
|
|
11
14
|
require_relative "../integration"
|
|
12
15
|
|
|
@@ -47,12 +50,22 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
47
50
|
options = ::Minitest.process_args(["--seed", "0"])
|
|
48
51
|
options[:io] = out
|
|
49
52
|
reporter = ::Minitest::CompositeReporter.new
|
|
50
|
-
|
|
53
|
+
summary = ::Minitest::SummaryReporter.new(out, options)
|
|
54
|
+
reporter << summary
|
|
51
55
|
initialize_minitest_state(reporter, options)
|
|
52
56
|
reporter.start
|
|
53
57
|
dispatch_minitest_suites(reporter, options)
|
|
54
58
|
reporter.report
|
|
55
|
-
|
|
59
|
+
Evilution::Baseline::Report.build(
|
|
60
|
+
passed: reporter.passed?, failures: baseline_failures(summary), failed_ids: TestIds.failed(summary)
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The summary reporter keeps every result that did not pass, skips included.
|
|
65
|
+
def self.baseline_failures(summary)
|
|
66
|
+
summary.results.reject(&:skipped?).map do |result|
|
|
67
|
+
{ id: TestIds.of(result), description: "", message: result.failure.message }
|
|
68
|
+
end
|
|
56
69
|
end
|
|
57
70
|
|
|
58
71
|
# Mirror Minitest.run's preamble: seed setup + plugin init. Without seeding
|
|
@@ -84,6 +97,12 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
84
97
|
end
|
|
85
98
|
end
|
|
86
99
|
|
|
100
|
+
# Shared by every instance: the canary and the mutation runs build their
|
|
101
|
+
# own, and a file loaded for one must not be loaded again for the other.
|
|
102
|
+
def self.test_classes
|
|
103
|
+
@test_classes ||= Evilution::Integration::Loading::TestClassCache.new { ::Minitest::Runnable.runnables.dup }
|
|
104
|
+
end
|
|
105
|
+
|
|
87
106
|
def self.baseline_options
|
|
88
107
|
{
|
|
89
108
|
runner: baseline_runner,
|
|
@@ -92,8 +111,10 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
92
111
|
}
|
|
93
112
|
end
|
|
94
113
|
|
|
95
|
-
|
|
114
|
+
# known_failures: the ids of the tests the baseline saw failing (see TestIds).
|
|
115
|
+
def initialize(test_files: nil, hooks: nil, fallback_to_full_suite: false, spec_selector: nil, known_failures: [])
|
|
96
116
|
@test_files = test_files
|
|
117
|
+
@known_failures = Evilution::Integration::KnownFailures.new(known_failures)
|
|
97
118
|
@minitest_loaded = false
|
|
98
119
|
@spec_selector = spec_selector || Evilution::SpecSelector.new(
|
|
99
120
|
spec_resolver: Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
|
|
@@ -132,17 +153,27 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
132
153
|
end
|
|
133
154
|
|
|
134
155
|
def execute_minitest(mutation, files, command)
|
|
135
|
-
|
|
136
|
-
files.each { |f| load(File.expand_path(f, Evilution.project_base_dir)) }
|
|
156
|
+
load_test_classes(files)
|
|
137
157
|
|
|
138
158
|
detector = reset_crash_detector
|
|
139
159
|
run = run_minitest(build_args(mutation), detector)
|
|
140
160
|
|
|
141
161
|
return no_tests_ran_result(command) if run[:count].zero?
|
|
162
|
+
# Before the crash check: a test that was already failing may fail by
|
|
163
|
+
# raising now, and a crash in it is no more a kill than its failure was.
|
|
164
|
+
return known_failures_only_result(command) if @known_failures.only?(run[:failed_ids])
|
|
142
165
|
|
|
143
166
|
build_minitest_result(run[:passed], command, detector)
|
|
144
167
|
end
|
|
145
168
|
|
|
169
|
+
# Leaves Minitest with the test classes of these files and no others.
|
|
170
|
+
def load_test_classes(files)
|
|
171
|
+
Evilution::Integration::Loading::TestLoadPath.add!(files)
|
|
172
|
+
paths = files.map { |f| File.expand_path(f, Evilution.project_base_dir) }
|
|
173
|
+
classes = self.class.test_classes.fetch(paths) { |path| load(path) }
|
|
174
|
+
::Minitest::Runnable.runnables.replace(classes)
|
|
175
|
+
end
|
|
176
|
+
|
|
146
177
|
# Zero dispatched test methods means the run carries no signal — the result
|
|
147
178
|
# is neither survived nor killed. Most common cause: the project's tests use
|
|
148
179
|
# a framework other than Minitest (e.g. the test-unit gem, whose
|
|
@@ -160,6 +191,12 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
160
191
|
}
|
|
161
192
|
end
|
|
162
193
|
|
|
194
|
+
# Every test that failed was already failing in the baseline. Still a
|
|
195
|
+
# failed run, but flagged, so it is not taken for a kill.
|
|
196
|
+
def known_failures_only_result(command)
|
|
197
|
+
{ passed: false, known_failures_only: true, test_command: command }
|
|
198
|
+
end
|
|
199
|
+
|
|
163
200
|
def unresolved_result(mutation)
|
|
164
201
|
{
|
|
165
202
|
passed: false,
|
|
@@ -190,7 +227,7 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
190
227
|
dispatch_minitest_suites(reporter, options)
|
|
191
228
|
reporter.report
|
|
192
229
|
|
|
193
|
-
{ passed: summary.passed?, count: minitest_method_count }
|
|
230
|
+
{ passed: summary.passed?, count: minitest_method_count, failed_ids: TestIds.failed(summary) }
|
|
194
231
|
end
|
|
195
232
|
|
|
196
233
|
# Attach evilution's own reporters to the composite AFTER plugin init, and
|
|
@@ -293,3 +330,5 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
293
330
|
end
|
|
294
331
|
end
|
|
295
332
|
end
|
|
333
|
+
|
|
334
|
+
require_relative "minitest/test_ids"
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "stringio"
|
|
3
4
|
require_relative "../rspec"
|
|
5
|
+
require_relative "../../baseline"
|
|
6
|
+
require_relative "example_ids"
|
|
4
7
|
|
|
5
8
|
class Evilution::Integration::RSpec::BaselineRunner
|
|
6
9
|
def call(spec_file)
|
|
@@ -9,10 +12,51 @@ class Evilution::Integration::RSpec::BaselineRunner
|
|
|
9
12
|
# FrameworkLoader#add_spec_load_path for rationale.
|
|
10
13
|
spec_dir = File.expand_path("spec", Evilution.project_base_dir)
|
|
11
14
|
$LOAD_PATH.unshift(spec_dir) unless $LOAD_PATH.include?(spec_dir)
|
|
12
|
-
|
|
15
|
+
reset_examples
|
|
16
|
+
output = StringIO.new
|
|
13
17
|
status = ::RSpec::Core::Runner.run(
|
|
14
|
-
["--format", "progress", "--no-color", "--order", "defined", spec_file]
|
|
18
|
+
["--format", "progress", "--no-color", "--order", "defined", spec_file], output, output
|
|
15
19
|
)
|
|
16
|
-
status.zero
|
|
20
|
+
report(status.zero?, output.string)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# Examples are cleared, the configuration is kept: a helper loaded by
|
|
26
|
+
# --preload is not loaded again by the spec's own require, so an
|
|
27
|
+
# RSpec.configure block dropped here never comes back, and a spec relying on
|
|
28
|
+
# it goes red in the baseline alone.
|
|
29
|
+
def reset_examples
|
|
30
|
+
::RSpec.respond_to?(:clear_examples) ? ::RSpec.clear_examples : ::RSpec.reset
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# What RSpec printed is the only account of a failure outside any example --
|
|
34
|
+
# a file that does not load, a suite hook that raises.
|
|
35
|
+
def report(passed, output)
|
|
36
|
+
return Evilution::Baseline::Report.build(passed: true) if passed
|
|
37
|
+
|
|
38
|
+
failures = failed_examples
|
|
39
|
+
Evilution::Baseline::Report.build(
|
|
40
|
+
passed: false, failures: failures, error: failures.empty? ? output : nil,
|
|
41
|
+
failed_ids: Evilution::Integration::RSpec::ExampleIds.failed(::RSpec.world)
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def failed_examples
|
|
46
|
+
world = ::RSpec.world
|
|
47
|
+
return [] unless world.respond_to?(:all_examples)
|
|
48
|
+
|
|
49
|
+
world.all_examples
|
|
50
|
+
.select { |example| example.execution_result.status == :failed }
|
|
51
|
+
.map { |example| failure(example) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def failure(example)
|
|
55
|
+
exception = example.execution_result.exception
|
|
56
|
+
{
|
|
57
|
+
id: example.id,
|
|
58
|
+
description: example.full_description,
|
|
59
|
+
message: exception ? "#{exception.class}: #{exception.message}" : nil
|
|
60
|
+
}
|
|
17
61
|
end
|
|
18
62
|
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rspec"
|
|
4
|
+
|
|
5
|
+
# Names an example the same way wherever it runs.
|
|
6
|
+
#
|
|
7
|
+
# RSpec's own id starts with the spec file as it was given: `./spec/a_spec.rb`
|
|
8
|
+
# from the project root, an absolute path from an isolated worker that has
|
|
9
|
+
# changed directory. The baseline and a mutation run can therefore disagree
|
|
10
|
+
# about the same example, so the path is made absolute and symlinks are
|
|
11
|
+
# resolved before the position within the file is added.
|
|
12
|
+
module Evilution::Integration::RSpec::ExampleIds
|
|
13
|
+
FAILED = :failed
|
|
14
|
+
|
|
15
|
+
def self.of(example)
|
|
16
|
+
metadata = example.metadata
|
|
17
|
+
"#{canonical(metadata[:rerun_file_path])}[#{metadata[:scoped_id]}]"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The ids of the examples that failed in the run RSpec.world still holds.
|
|
21
|
+
def self.failed(world)
|
|
22
|
+
return [] unless world.respond_to?(:all_examples)
|
|
23
|
+
|
|
24
|
+
world.all_examples
|
|
25
|
+
.select { |example| example.execution_result.status == FAILED }
|
|
26
|
+
.map { |example| of(example) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.canonical(path)
|
|
30
|
+
File.realpath(path)
|
|
31
|
+
rescue SystemCallError
|
|
32
|
+
File.expand_path(path)
|
|
33
|
+
end
|
|
34
|
+
private_class_method :canonical
|
|
35
|
+
end
|
|
@@ -21,8 +21,13 @@ class Evilution::Integration::RSpec::ResultBuilder
|
|
|
21
21
|
}
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
# known_failures_only: every example that failed was already failing in the
|
|
25
|
+
# baseline. Still a failed run, but flagged, so it is not taken for a kill.
|
|
26
|
+
def from_run(status, command, detector, examples_loaded: nil, known_failures_only: false)
|
|
25
27
|
return { passed: true, test_command: command } if status.zero?
|
|
28
|
+
# Before the crash check: an example that was already failing may fail by
|
|
29
|
+
# raising now, and a crash in it is no more a kill than its failure was.
|
|
30
|
+
return { passed: false, known_failures_only: true, test_command: command } if known_failures_only
|
|
26
31
|
|
|
27
32
|
if detector.only_crashes?
|
|
28
33
|
classes = detector.unique_crash_classes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../state_guard"
|
|
4
|
+
require_relative "../../rspec"
|
|
5
|
+
|
|
6
|
+
# Drops the examples a run registers on RSpec::Core::AnonymousExampleGroup.
|
|
7
|
+
#
|
|
8
|
+
# Example#initialize appends every example to its group's `examples`. Most
|
|
9
|
+
# groups are thrown away with the run, but AnonymousExampleGroup is a constant:
|
|
10
|
+
# whatever lands there stays for the life of the process. A run with a suite
|
|
11
|
+
# hook puts a SuiteHookContext there, and the reporter notifying a non-example
|
|
12
|
+
# exception puts an Example there. Each holds that run's reporter, and the
|
|
13
|
+
# reporter holds every example the run loaded, so one in-process run after
|
|
14
|
+
# another retained them all, about 1 MB per mutation on the memory_check
|
|
15
|
+
# fixture.
|
|
16
|
+
#
|
|
17
|
+
# Forked runs never leak this (the child dies), only the in-process path.
|
|
18
|
+
class Evilution::Integration::RSpec::StateGuard::AnonymousExampleGroupExamples
|
|
19
|
+
def snapshot
|
|
20
|
+
examples&.length
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def release(length)
|
|
24
|
+
return unless length
|
|
25
|
+
|
|
26
|
+
examples&.slice!(length..)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def examples
|
|
32
|
+
return nil unless defined?(::RSpec::Core::AnonymousExampleGroup)
|
|
33
|
+
|
|
34
|
+
::RSpec::Core::AnonymousExampleGroup.examples
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -12,8 +12,10 @@ require_relative "state_guard/world_example_groups"
|
|
|
12
12
|
require_relative "state_guard/world_sources_by_path"
|
|
13
13
|
require_relative "state_guard/world_filtered_examples"
|
|
14
14
|
require_relative "state_guard/reporter_arrays"
|
|
15
|
+
require_relative "state_guard/anonymous_example_group_examples"
|
|
15
16
|
require_relative "state_guard/example_groups_constants"
|
|
16
17
|
require_relative "state_guard/configuration_state"
|
|
18
|
+
require_relative "../../diagnostic"
|
|
17
19
|
|
|
18
20
|
class Evilution::Integration::RSpec::StateGuard
|
|
19
21
|
DEFAULT_STRATEGIES = [
|
|
@@ -22,6 +24,7 @@ class Evilution::Integration::RSpec::StateGuard
|
|
|
22
24
|
WorldSourcesByPath.new,
|
|
23
25
|
WorldFilteredExamples.new,
|
|
24
26
|
ReporterArrays.new,
|
|
27
|
+
AnonymousExampleGroupExamples.new,
|
|
25
28
|
ExampleGroupsConstants.new,
|
|
26
29
|
ConfigurationState.new
|
|
27
30
|
].freeze
|
|
@@ -43,6 +46,6 @@ class Evilution::Integration::RSpec::StateGuard
|
|
|
43
46
|
def release_one(strategy, captured)
|
|
44
47
|
strategy.release(captured)
|
|
45
48
|
rescue StandardError => e
|
|
46
|
-
warn
|
|
49
|
+
Evilution::Diagnostic.warn("[evilution] state release failed for #{strategy.class.name}: #{e.class}: #{e.message}")
|
|
47
50
|
end
|
|
48
51
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "stringio"
|
|
4
4
|
require_relative "base"
|
|
5
|
+
require_relative "known_failures"
|
|
5
6
|
require_relative "../spec_resolver"
|
|
6
7
|
require_relative "../spec_selector"
|
|
7
8
|
require_relative "../related_spec_heuristic"
|
|
@@ -24,6 +25,7 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
24
25
|
fallback_to_full_suite: false,
|
|
25
26
|
spec_selector: nil,
|
|
26
27
|
example_filter: nil,
|
|
28
|
+
known_failures: [],
|
|
27
29
|
framework_loader: FrameworkLoader.new,
|
|
28
30
|
test_file_resolver: nil,
|
|
29
31
|
example_filter_applier: nil,
|
|
@@ -44,6 +46,7 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
44
46
|
@crash_detector_lifecycle = crash_detector_lifecycle
|
|
45
47
|
@result_builder = result_builder
|
|
46
48
|
@state_guard = state_guard
|
|
49
|
+
@known_failures = Evilution::Integration::KnownFailures.new(known_failures)
|
|
47
50
|
super(hooks: hooks)
|
|
48
51
|
end
|
|
49
52
|
|
|
@@ -71,7 +74,7 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
71
74
|
return @result_builder.unresolved_example(mutation) if targets.nil?
|
|
72
75
|
|
|
73
76
|
result = run_targets(targets)
|
|
74
|
-
return result unless
|
|
77
|
+
return result unless confirm?(result, targets, files)
|
|
75
78
|
|
|
76
79
|
run_targets(files)
|
|
77
80
|
end
|
|
@@ -85,8 +88,12 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
85
88
|
# So a survivor is re-run against the whole resolved file, and that run is the
|
|
86
89
|
# one reported. Only survivors pay for it, and only where the subset was
|
|
87
90
|
# actually narrower than the file.
|
|
88
|
-
|
|
89
|
-
|
|
91
|
+
#
|
|
92
|
+
# The same goes for a subset in which nothing failed but examples that were
|
|
93
|
+
# failing before any mutation ran: that is no verdict either, and the rest of
|
|
94
|
+
# the file may hold one.
|
|
95
|
+
def confirm?(result, targets, files)
|
|
96
|
+
(result[:passed] || result[:known_failures_only]) && targets != files
|
|
90
97
|
end
|
|
91
98
|
|
|
92
99
|
def run_targets(targets)
|
|
@@ -142,7 +149,8 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
142
149
|
claim_rspec_streams(out, err)
|
|
143
150
|
detector = @crash_detector_lifecycle.register
|
|
144
151
|
status = ::RSpec::Core::Runner.run(args, err, out)
|
|
145
|
-
|
|
152
|
+
flagged = known_failures_only?(status)
|
|
153
|
+
@result_builder.from_run(status, command, detector, examples_loaded:, known_failures_only: flagged)
|
|
146
154
|
rescue StandardError => e
|
|
147
155
|
{ passed: false, error: e.message, test_command: command }
|
|
148
156
|
ensure
|
|
@@ -188,12 +196,22 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
|
|
|
188
196
|
nil
|
|
189
197
|
end
|
|
190
198
|
|
|
199
|
+
# Whether the run failed on nothing but examples the baseline already saw
|
|
200
|
+
# failing. Decided here, in the process that ran them: the names of every
|
|
201
|
+
# failing example need not travel back, only the answer.
|
|
202
|
+
def known_failures_only?(status)
|
|
203
|
+
return false if status.zero? || @known_failures.empty?
|
|
204
|
+
|
|
205
|
+
@known_failures.only?(ExampleIds.failed(::RSpec.world))
|
|
206
|
+
end
|
|
207
|
+
|
|
191
208
|
def reset_examples
|
|
192
209
|
::RSpec.respond_to?(:clear_examples) ? ::RSpec.clear_examples : ::RSpec.reset
|
|
193
210
|
end
|
|
194
211
|
end
|
|
195
212
|
|
|
196
213
|
require_relative "rspec/framework_loader"
|
|
214
|
+
require_relative "rspec/example_ids"
|
|
197
215
|
require_relative "rspec/test_file_resolver"
|
|
198
216
|
require_relative "rspec/unresolved_spec_warner"
|
|
199
217
|
require_relative "rspec/example_filter_applier"
|
|
@@ -21,6 +21,12 @@ module Evilution::Integration::TestUnit::ResultBuilder
|
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# Every test that failed was already failing in the baseline. Still a
|
|
25
|
+
# failed run, but flagged, so it is not taken for a kill.
|
|
26
|
+
def known_failures_only(command)
|
|
27
|
+
{ passed: false, known_failures_only: true, test_command: command }
|
|
28
|
+
end
|
|
29
|
+
|
|
24
30
|
def no_tests_ran(command)
|
|
25
31
|
{
|
|
26
32
|
passed: false,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_unit"
|
|
4
|
+
|
|
5
|
+
# Names a Test::Unit test the same way in the baseline and in a mutation run:
|
|
6
|
+
# the name the framework gives it, `test_adds(CalcTest)`.
|
|
7
|
+
module Evilution::Integration::TestUnit::TestIds
|
|
8
|
+
# Only failures and errors fail a run; pendings, omissions and notifications
|
|
9
|
+
# are faults too, and are left out.
|
|
10
|
+
def self.faults(result)
|
|
11
|
+
result.failures + result.errors
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# The ids of the tests that failed or raised in a finished run.
|
|
15
|
+
def self.failed(result)
|
|
16
|
+
faults(result).map(&:test_name)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
4
|
require_relative "test_unit_crash_detector"
|
|
5
|
+
require_relative "known_failures"
|
|
5
6
|
require_relative "loading/test_load_path"
|
|
7
|
+
require_relative "loading/test_class_cache"
|
|
6
8
|
require_relative "../spec_resolver"
|
|
7
9
|
require_relative "../spec_selector"
|
|
10
|
+
require_relative "../baseline"
|
|
8
11
|
|
|
9
12
|
require_relative "../integration"
|
|
10
13
|
|
|
@@ -28,6 +31,12 @@ class Evilution::Integration::TestUnit < Evilution::Integration::Base
|
|
|
28
31
|
Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
|
|
29
32
|
end
|
|
30
33
|
|
|
34
|
+
# Shared by every instance: the canary and the mutation runs build their
|
|
35
|
+
# own, and a file loaded for one must not be loaded again for the other.
|
|
36
|
+
def self.test_classes
|
|
37
|
+
@test_classes ||= Evilution::Integration::Loading::TestClassCache.new { SubjectClassRegistry.descendants }
|
|
38
|
+
end
|
|
39
|
+
|
|
31
40
|
def self.baseline_options
|
|
32
41
|
{
|
|
33
42
|
runner: baseline_runner,
|
|
@@ -40,25 +49,41 @@ class Evilution::Integration::TestUnit < Evilution::Integration::Base
|
|
|
40
49
|
require_relative "test_unit/framework_loader"
|
|
41
50
|
require_relative "test_unit/subject_class_registry"
|
|
42
51
|
require_relative "test_unit/dispatcher"
|
|
52
|
+
require_relative "test_unit/test_ids"
|
|
43
53
|
FrameworkLoader.new.call
|
|
44
54
|
files = baseline_test_files(test_file)
|
|
45
55
|
Evilution::Integration::Loading::TestLoadPath.add!(files)
|
|
46
56
|
new_classes = SubjectClassRegistry.newly_loaded do
|
|
47
57
|
files.each { |f| load(File.expand_path(f)) }
|
|
48
58
|
end
|
|
49
|
-
Dispatcher.call(new_classes, name: "evilution baseline")
|
|
59
|
+
baseline_report(Dispatcher.call(new_classes, name: "evilution baseline"))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.baseline_report(result)
|
|
63
|
+
Evilution::Baseline::Report.build(
|
|
64
|
+
passed: result.passed?, failures: baseline_failures(result), failed_ids: TestIds.failed(result)
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.baseline_failures(result)
|
|
69
|
+
TestIds.faults(result).map do |fault|
|
|
70
|
+
{ id: fault.test_name, description: "", message: fault.message }
|
|
71
|
+
end
|
|
50
72
|
end
|
|
51
73
|
|
|
52
74
|
def self.baseline_test_files(test_file)
|
|
53
75
|
File.directory?(test_file) ? Dir.glob(File.join(test_file, "**/*_test.rb")) : [test_file]
|
|
54
76
|
end
|
|
55
77
|
|
|
56
|
-
|
|
78
|
+
# known_failures: the ids of the tests the baseline saw failing (see TestIds).
|
|
79
|
+
def initialize(test_files: nil, hooks: nil, fallback_to_full_suite: false, spec_selector: nil, known_failures: [])
|
|
57
80
|
require_relative "test_unit/framework_loader"
|
|
58
81
|
require_relative "test_unit/subject_class_registry"
|
|
59
82
|
require_relative "test_unit/dispatcher"
|
|
60
83
|
require_relative "test_unit/test_file_resolver"
|
|
61
84
|
require_relative "test_unit/result_builder"
|
|
85
|
+
require_relative "test_unit/test_ids"
|
|
86
|
+
@known_failures = Evilution::Integration::KnownFailures.new(known_failures)
|
|
62
87
|
@framework_loader = FrameworkLoader.new
|
|
63
88
|
@file_resolver = TestFileResolver.new(
|
|
64
89
|
test_files: test_files,
|
|
@@ -97,22 +122,25 @@ class Evilution::Integration::TestUnit < Evilution::Integration::Base
|
|
|
97
122
|
|
|
98
123
|
detector = reset_crash_detector
|
|
99
124
|
result = Dispatcher.call(new_classes, name: "evilution-mutation")
|
|
125
|
+
# Before the crash check: a test that was already failing may fail by
|
|
126
|
+
# raising now, and a crash in it is no more a kill than its failure was.
|
|
127
|
+
return ResultBuilder.known_failures_only(command) if @known_failures.only?(TestIds.failed(result))
|
|
128
|
+
|
|
100
129
|
result.faults.each { |fault| detector.record(fault) }
|
|
101
130
|
ResultBuilder.call(passed: result.passed?, command: command, detector: detector)
|
|
102
131
|
end
|
|
103
132
|
|
|
104
133
|
def load_test_classes(files)
|
|
105
134
|
Evilution::Integration::Loading::TestLoadPath.add!(files)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
end
|
|
135
|
+
paths = files.map { |f| File.expand_path(f, Evilution.project_base_dir) }
|
|
136
|
+
self.class.test_classes.fetch(paths) { |path| load(path) }
|
|
109
137
|
end
|
|
110
138
|
|
|
111
139
|
# Test::Unit has no public registry-clear analogous to
|
|
112
|
-
# Minitest::Runnable.runnables.clear.
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
140
|
+
# Minitest::Runnable.runnables.clear. Each dispatch is handed the classes of
|
|
141
|
+
# the files resolved for this mutation, so classes of other files sit
|
|
142
|
+
# dormant on ObjectSpace without polluting the run. #reset_state stays as a
|
|
143
|
+
# contract no-op for parity with Minitest.
|
|
116
144
|
def reset_state
|
|
117
145
|
# no-op — see comment above
|
|
118
146
|
end
|