evilution 0.32.0 → 0.34.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 +31 -0
- data/README.md +12 -10
- data/docs/integrations.md +15 -0
- data/docs/isolation.md +46 -2
- data/lib/evilution/baseline.rb +11 -4
- data/lib/evilution/cli/parser/options_builder.rb +17 -0
- data/lib/evilution/config/validators/example_targeting_strategy.rb +22 -0
- data/lib/evilution/config.rb +16 -2
- data/lib/evilution/coverage/digest.rb +16 -0
- data/lib/evilution/coverage/map.rb +64 -0
- data/lib/evilution/coverage/map_builder.rb +82 -0
- data/lib/evilution/coverage/map_store.rb +87 -0
- data/lib/evilution/coverage/recorder.rb +85 -0
- data/lib/evilution/coverage.rb +8 -0
- data/lib/evilution/coverage_example_filter.rb +41 -0
- data/lib/evilution/integration/loading/test_load_path.rb +76 -0
- data/lib/evilution/integration/minitest.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.rb +12 -4
- data/lib/evilution/isolation/fork.rb +38 -50
- data/lib/evilution/parallel/work_queue/dispatcher/deadline_tracker.rb +63 -0
- data/lib/evilution/parallel/work_queue/dispatcher.rb +70 -25
- data/lib/evilution/parallel/work_queue/worker.rb +50 -14
- data/lib/evilution/parallel/work_queue.rb +8 -0
- data/lib/evilution/process_supervisor.rb +259 -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 +52 -0
- data/lib/evilution/runner/isolation_resolver.rb +106 -12
- data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +28 -1
- data/lib/evilution/runner.rb +7 -0
- data/lib/evilution/spec_resolver.rb +147 -9
- data/lib/evilution/spec_selector.rb +14 -4
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +1 -0
- data/lib/tasks/stress.rake +15 -0
- data/scripts/canary_manifest.yml +47 -0
- data/scripts/compare_targeting +277 -0
- data/scripts/compare_targeting.example.yml +24 -0
- metadata +20 -2
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "coverage"
|
|
4
|
+
require "stringio"
|
|
5
|
+
require_relative "../coverage"
|
|
6
|
+
require_relative "map"
|
|
7
|
+
require_relative "recorder"
|
|
8
|
+
|
|
9
|
+
# Builds a per-example coverage Map by running the given spec files under
|
|
10
|
+
# ::Coverage (lines mode) with the Recorder installed as an around(:each)
|
|
11
|
+
# hook. The run happens in a FORKED CHILD so the global ::RSpec.configure
|
|
12
|
+
# hook, ::RSpec.world mutation, and ::Coverage state never leak into the
|
|
13
|
+
# calling process; the parent receives only the serialized map over a pipe.
|
|
14
|
+
class Evilution::Coverage::MapBuilder
|
|
15
|
+
LOCATION_LINE_SUFFIX = /\A(.+?)(:\d+(?::\d+)*)\z/
|
|
16
|
+
|
|
17
|
+
# RSpec reports example locations relative to its run dir as "./spec/x.rb:5".
|
|
18
|
+
# Store them ABSOLUTE (path expanded against the project root, line suffix
|
|
19
|
+
# preserved) so they replay regardless of the per-mutation run's CWD --
|
|
20
|
+
# Integration::RSpec#resolve_target passes absolute targets through unchanged.
|
|
21
|
+
def self.absolute_location(raw, root)
|
|
22
|
+
match = LOCATION_LINE_SUFFIX.match(raw)
|
|
23
|
+
path, suffix = match ? [match[1], match[2]] : [raw, ""]
|
|
24
|
+
"#{File.expand_path(path, root)}#{suffix}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(spec_files:, target_files:, project_root: Evilution::PROJECT_ROOT)
|
|
28
|
+
@spec_files = spec_files
|
|
29
|
+
@target_files = target_files
|
|
30
|
+
@project_root = project_root.to_s
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call
|
|
34
|
+
read_io, write_io = IO.pipe
|
|
35
|
+
read_io.binmode
|
|
36
|
+
write_io.binmode
|
|
37
|
+
pid = fork do
|
|
38
|
+
read_io.close
|
|
39
|
+
Marshal.dump(build_in_child.to_h, write_io)
|
|
40
|
+
write_io.close
|
|
41
|
+
exit!(0)
|
|
42
|
+
end
|
|
43
|
+
write_io.close
|
|
44
|
+
payload = read_io.read
|
|
45
|
+
read_io.close
|
|
46
|
+
Process.wait(pid)
|
|
47
|
+
# Trust boundary: payload is a Marshal dump this process's own forked child
|
|
48
|
+
# produced over a private pipe -- same rationale as Channel::Frame.
|
|
49
|
+
Evilution::Coverage::Map.from_h(Marshal.load(payload))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Runs entirely inside the forked child.
|
|
55
|
+
def build_in_child
|
|
56
|
+
recorder = Evilution::Coverage::Recorder.new(target_files: @target_files)
|
|
57
|
+
::Coverage.start(lines: true) unless ::Coverage.running?
|
|
58
|
+
# The child inherited the parent's fully-loaded RSpec.world; wipe it so the
|
|
59
|
+
# nested runner executes ONLY @spec_files and never re-enters the host suite
|
|
60
|
+
# (which would recursively fork this builder).
|
|
61
|
+
reset_world
|
|
62
|
+
install_hook(recorder)
|
|
63
|
+
::RSpec::Core::Runner.run(@spec_files, StringIO.new, StringIO.new)
|
|
64
|
+
recorder.to_map(built_files: @target_files)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def reset_world
|
|
68
|
+
::RSpec.respond_to?(:clear_examples) ? ::RSpec.clear_examples : ::RSpec.reset
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def install_hook(recorder)
|
|
72
|
+
root = @project_root
|
|
73
|
+
::RSpec.configure do |config|
|
|
74
|
+
config.around(:each) do |example|
|
|
75
|
+
location = Evilution::Coverage::MapBuilder.absolute_location(
|
|
76
|
+
example.metadata[:location].to_s, root
|
|
77
|
+
)
|
|
78
|
+
recorder.around_example(location) { example.run }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "../coverage"
|
|
6
|
+
require_relative "map"
|
|
7
|
+
require_relative "digest"
|
|
8
|
+
|
|
9
|
+
# Disk cache for a per-example coverage Map under .evilution/coverage/, keyed by
|
|
10
|
+
# a per-file content digest so a map survives across runs and invalidates one
|
|
11
|
+
# file at a time.
|
|
12
|
+
#
|
|
13
|
+
# load is partial: it returns a Map pruned to the files whose on-disk content
|
|
14
|
+
# still matches the cached digest. A stale or deleted file is dropped (so its
|
|
15
|
+
# `built?` is false and the caller falls back to lexical targeting) while every
|
|
16
|
+
# fresh file stays queryable. A missing or corrupt cache returns nil, signalling
|
|
17
|
+
# the caller to rebuild from scratch.
|
|
18
|
+
class Evilution::Coverage::MapStore
|
|
19
|
+
DEFAULT_ROOT = ".evilution/coverage"
|
|
20
|
+
CACHE_FILE = "map.json"
|
|
21
|
+
|
|
22
|
+
def initialize(root: DEFAULT_ROOT, digest: Evilution::Coverage::Digest.new)
|
|
23
|
+
@root = root
|
|
24
|
+
@digest = digest
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def save(map, source_files)
|
|
28
|
+
payload = { "digests" => digests_for(source_files), "map" => map.to_h }
|
|
29
|
+
FileUtils.mkdir_p(@root)
|
|
30
|
+
File.write(cache_path, JSON.generate(payload))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def load(source_files)
|
|
34
|
+
payload = read_payload
|
|
35
|
+
return nil unless payload
|
|
36
|
+
|
|
37
|
+
cached_digests = payload["digests"] || {}
|
|
38
|
+
fresh = source_files.select { |file| fresh?(file, cached_digests) }
|
|
39
|
+
pruned_map(payload["map"] || {}, fresh)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Source files whose on-disk content no longer matches the cache (changed,
|
|
43
|
+
# deleted, or never cached) -- the caller rebuilds these. Every file is stale
|
|
44
|
+
# when there is no cache at all.
|
|
45
|
+
def stale_files(source_files)
|
|
46
|
+
payload = read_payload
|
|
47
|
+
return source_files.dup unless payload
|
|
48
|
+
|
|
49
|
+
cached_digests = payload["digests"] || {}
|
|
50
|
+
source_files.reject { |file| fresh?(file, cached_digests) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def fresh?(file, cached_digests)
|
|
56
|
+
cached = cached_digests[file]
|
|
57
|
+
!cached.nil? && cached == @digest.for_file(file)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def pruned_map(raw_map, fresh_files)
|
|
61
|
+
index = (raw_map["index"] || {}).slice(*fresh_files)
|
|
62
|
+
built = (raw_map["built_files"] || []) & fresh_files
|
|
63
|
+
executed = (raw_map["executed_lines"] || {}).slice(*fresh_files)
|
|
64
|
+
Evilution::Coverage::Map.from_h(
|
|
65
|
+
"index" => index, "built_files" => built, "executed_lines" => executed
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def digests_for(source_files)
|
|
70
|
+
source_files.each_with_object({}) do |file, out|
|
|
71
|
+
digest = @digest.for_file(file)
|
|
72
|
+
out[file] = digest unless digest.nil?
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def read_payload
|
|
77
|
+
return nil unless File.file?(cache_path)
|
|
78
|
+
|
|
79
|
+
JSON.parse(File.read(cache_path))
|
|
80
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def cache_path
|
|
85
|
+
File.join(@root, CACHE_FILE)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../coverage"
|
|
4
|
+
require_relative "map"
|
|
5
|
+
|
|
6
|
+
# Wraps each example with a before/after coverage diff and attributes the
|
|
7
|
+
# newly-executed lines (in target files only) to that example's location.
|
|
8
|
+
# coverage_source is injected for testability; in production it is
|
|
9
|
+
# -> { ::Coverage.peek_result }.
|
|
10
|
+
class Evilution::Coverage::Recorder
|
|
11
|
+
def initialize(target_files:, coverage_source: -> { ::Coverage.peek_result })
|
|
12
|
+
@target_files = target_files.to_a
|
|
13
|
+
@coverage_source = coverage_source
|
|
14
|
+
@index = Hash.new { |h, file| h[file] = Hash.new { |g, line| g[line] = [] } }
|
|
15
|
+
@executed = Hash.new { |h, file| h[file] = [] }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def around_example(example_location)
|
|
19
|
+
before = snapshot
|
|
20
|
+
result = yield
|
|
21
|
+
after = snapshot
|
|
22
|
+
attribute(before, after, example_location)
|
|
23
|
+
result
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_map(built_files:)
|
|
27
|
+
Evilution::Coverage::Map.new(
|
|
28
|
+
index: materialize(@index),
|
|
29
|
+
built_files: built_files,
|
|
30
|
+
executed_lines: @executed.transform_values(&:uniq)
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def snapshot
|
|
37
|
+
@coverage_source.call || {}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def attribute(before, after, example_location)
|
|
41
|
+
@target_files.each do |file|
|
|
42
|
+
after_counts = line_counts(after[file])
|
|
43
|
+
next unless after_counts
|
|
44
|
+
|
|
45
|
+
record_executed(file, after_counts)
|
|
46
|
+
record_increases(file, line_counts(before[file]) || [], after_counts, example_location)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Every line with a non-zero count in the after-snapshot has run at least once
|
|
51
|
+
# by now -- including lines covered only at load (a `def` line is already > 0
|
|
52
|
+
# in the first example's after-snapshot). Recording them lets the Map tell a
|
|
53
|
+
# load-covered line from a line that never ran.
|
|
54
|
+
def record_executed(file, after_counts)
|
|
55
|
+
after_counts.each_with_index do |count, idx|
|
|
56
|
+
next if count.nil? || count.zero?
|
|
57
|
+
|
|
58
|
+
@executed[file] << (idx + 1)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Credit example_location with every line whose execution count rose between
|
|
63
|
+
# the before/after snapshots (a newly-executed, executable line).
|
|
64
|
+
def record_increases(file, before_counts, after_counts, example_location)
|
|
65
|
+
after_counts.each_with_index do |count, idx|
|
|
66
|
+
next if count.nil? || count.zero?
|
|
67
|
+
next unless count > (before_counts[idx] || 0)
|
|
68
|
+
|
|
69
|
+
@index[file][idx + 1] << example_location
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Coverage.peek_result yields per-file line counts either as a bare array
|
|
74
|
+
# (legacy Coverage.start) or as a { lines: [...] } hash (Coverage.start with
|
|
75
|
+
# lines:/branches:/methods: modes). Normalize to the bare counts array.
|
|
76
|
+
def line_counts(entry)
|
|
77
|
+
entry.is_a?(Hash) ? entry[:lines] : entry
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def materialize(index)
|
|
81
|
+
index.each_with_object({}) do |(file, lines), out|
|
|
82
|
+
out[file] = lines.each_with_object({}) { |(line, locs), inner| inner[line] = locs }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../evilution"
|
|
4
|
+
require_relative "coverage/map"
|
|
5
|
+
|
|
6
|
+
# Per-mutation example targeting backed by a real line-coverage Map (EV-ndjd).
|
|
7
|
+
# Honours the same contract as the lexical Evilution::ExampleFilter --
|
|
8
|
+
# call(mutation, spec_paths) -> Array[location] | spec_paths | nil -- so it drops
|
|
9
|
+
# straight into the existing ExampleFilter seam.
|
|
10
|
+
#
|
|
11
|
+
# Resolution order for the mutated source file F at line L:
|
|
12
|
+
# - F not fully built in the map (digest miss / partial build) -> delegate to
|
|
13
|
+
# the lexical filter (safe fallback) with the original spec_paths.
|
|
14
|
+
# - F built and L covered by examples -> run exactly those covering examples
|
|
15
|
+
# (a SUBSET of what the resolved spec runs, so a strict speedup that cannot
|
|
16
|
+
# lose a kill full-file would catch).
|
|
17
|
+
# - F not built, or L attributed to no example -> defer to lexical/full-file.
|
|
18
|
+
#
|
|
19
|
+
# Accuracy-first: coverage ONLY narrows the example set when it positively knows
|
|
20
|
+
# the covering examples. It never marks a mutation :unresolved on "no coverage" --
|
|
21
|
+
# on real repos a line can be exercised indirectly (before(:all), load time, a
|
|
22
|
+
# spec the per-example diff did not attribute), and asserting a gap there loses
|
|
23
|
+
# kills (EV-7uui validation). When coverage has no answer, the proven lexical
|
|
24
|
+
# path decides.
|
|
25
|
+
class Evilution::CoverageExampleFilter
|
|
26
|
+
def initialize(map:, lexical:, project_root: Evilution::PROJECT_ROOT)
|
|
27
|
+
@map = map
|
|
28
|
+
@lexical = lexical
|
|
29
|
+
@project_root = project_root.to_s
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call(mutation, spec_paths)
|
|
33
|
+
file = File.expand_path(mutation.file_path, @project_root)
|
|
34
|
+
return @lexical.call(mutation, spec_paths) unless @map.built?(file)
|
|
35
|
+
|
|
36
|
+
examples = @map.examples_for(file, mutation.line)
|
|
37
|
+
return examples unless examples.empty?
|
|
38
|
+
|
|
39
|
+
@lexical.call(mutation, spec_paths)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../loading"
|
|
4
|
+
|
|
5
|
+
# Mirrors `ruby -Itest` / `-Ispec` for in-process test loading.
|
|
6
|
+
#
|
|
7
|
+
# evilution loads resolved test files with Kernel#load instead of shelling out,
|
|
8
|
+
# so the `-Itest` shown in the displayed command string is never actually
|
|
9
|
+
# applied to $LOAD_PATH. Minitest and Test::Unit suites near-universally
|
|
10
|
+
# `require "test_helper"` (which the suite's own runner satisfies via -Itest);
|
|
11
|
+
# without the test root on $LOAD_PATH that bare require raises LoadError and
|
|
12
|
+
# every mutation errors with score 0.0 (EV-52hf / GH #1326).
|
|
13
|
+
#
|
|
14
|
+
# Anchors against Evilution.project_base_dir, which resolves to PROJECT_ROOT
|
|
15
|
+
# inside an isolated worker (EV-wqxu / GH #1278) and Dir.pwd otherwise, so the
|
|
16
|
+
# same call works on both the baseline (parent) and mutation (child) paths.
|
|
17
|
+
module Evilution::Integration::Loading::TestLoadPath
|
|
18
|
+
ROOT_NAMES = %w[test spec].freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Prepend every relevant test directory to $LOAD_PATH (idempotently).
|
|
23
|
+
# Iterate in reverse so the first entry from #dirs_for ends up frontmost,
|
|
24
|
+
# preserving its order (mirrors how `ruby -Ia -Ib` lands a before b).
|
|
25
|
+
def add!(files, base: Evilution.project_base_dir)
|
|
26
|
+
dirs_for(files, base).reverse_each do |dir|
|
|
27
|
+
$LOAD_PATH.unshift(dir) unless $LOAD_PATH.include?(dir)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The directories to put on $LOAD_PATH for the given resolved test files:
|
|
32
|
+
# the conventional test/ and spec/ roots under base, each file's own
|
|
33
|
+
# directory, and the topmost test/spec ancestor of each file (covers nested
|
|
34
|
+
# layouts like test/unit, spec/lib, spec/unit). Existing directories only,
|
|
35
|
+
# and only those inside the project base -- never a broad outside-project dir
|
|
36
|
+
# (e.g. a /tmp test file), which would over-widen $LOAD_PATH for the whole
|
|
37
|
+
# process (the baseline runs in the long-lived parent).
|
|
38
|
+
def dirs_for(files, base)
|
|
39
|
+
base = File.expand_path(base)
|
|
40
|
+
dirs = conventional_roots(base)
|
|
41
|
+
Array(files).each do |file|
|
|
42
|
+
file_dir = File.dirname(File.expand_path(file, base))
|
|
43
|
+
dirs << file_dir
|
|
44
|
+
root = root_ancestor(file_dir, base)
|
|
45
|
+
dirs << root if root
|
|
46
|
+
end
|
|
47
|
+
dirs.uniq.select { |dir| File.directory?(dir) && within?(dir, base) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def within?(dir, base)
|
|
51
|
+
dir == base || dir.start_with?("#{base}/")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def conventional_roots(base)
|
|
55
|
+
ROOT_NAMES.map { |name| File.join(base, name) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Walk from `dir` up to `base`, returning the highest ancestor whose basename
|
|
59
|
+
# is a conventional test root (test/spec). Highest, so test/unit/foo_test.rb
|
|
60
|
+
# yields `test` (matching -Itest), not the intermediate test/unit.
|
|
61
|
+
def root_ancestor(dir, base)
|
|
62
|
+
base = File.expand_path(base)
|
|
63
|
+
found = nil
|
|
64
|
+
current = File.expand_path(dir)
|
|
65
|
+
loop do
|
|
66
|
+
found = current if ROOT_NAMES.include?(File.basename(current))
|
|
67
|
+
break if current == base
|
|
68
|
+
|
|
69
|
+
parent = File.dirname(current)
|
|
70
|
+
break if parent == current
|
|
71
|
+
|
|
72
|
+
current = parent
|
|
73
|
+
end
|
|
74
|
+
found
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "stringio"
|
|
4
4
|
require_relative "base"
|
|
5
5
|
require_relative "minitest_crash_detector"
|
|
6
|
+
require_relative "loading/test_load_path"
|
|
6
7
|
require_relative "../spec_resolver"
|
|
7
8
|
require_relative "../spec_selector"
|
|
8
9
|
|
|
@@ -18,7 +19,9 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
18
19
|
require "stringio"
|
|
19
20
|
stub_autorun!
|
|
20
21
|
::Minitest::Runnable.runnables.clear
|
|
21
|
-
baseline_test_files(test_file)
|
|
22
|
+
files = baseline_test_files(test_file)
|
|
23
|
+
Evilution::Integration::Loading::TestLoadPath.add!(files)
|
|
24
|
+
files.each { |f| load(File.expand_path(f)) }
|
|
22
25
|
run_baseline_minitest
|
|
23
26
|
end
|
|
24
27
|
|
|
@@ -128,6 +131,7 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
128
131
|
end
|
|
129
132
|
|
|
130
133
|
def execute_minitest(mutation, files, command)
|
|
134
|
+
Evilution::Integration::Loading::TestLoadPath.add!(files)
|
|
131
135
|
files.each { |f| load(File.expand_path(f, Evilution.project_base_dir)) }
|
|
132
136
|
|
|
133
137
|
detector = reset_crash_detector
|
|
@@ -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)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
4
|
require_relative "test_unit_crash_detector"
|
|
5
|
+
require_relative "loading/test_load_path"
|
|
5
6
|
require_relative "../spec_resolver"
|
|
6
7
|
require_relative "../spec_selector"
|
|
7
8
|
|
|
@@ -40,8 +41,10 @@ class Evilution::Integration::TestUnit < Evilution::Integration::Base
|
|
|
40
41
|
require_relative "test_unit/subject_class_registry"
|
|
41
42
|
require_relative "test_unit/dispatcher"
|
|
42
43
|
FrameworkLoader.new.call
|
|
44
|
+
files = baseline_test_files(test_file)
|
|
45
|
+
Evilution::Integration::Loading::TestLoadPath.add!(files)
|
|
43
46
|
new_classes = SubjectClassRegistry.newly_loaded do
|
|
44
|
-
|
|
47
|
+
files.each { |f| load(File.expand_path(f)) }
|
|
45
48
|
end
|
|
46
49
|
Dispatcher.call(new_classes, name: "evilution baseline").passed?
|
|
47
50
|
end
|
|
@@ -89,9 +92,7 @@ class Evilution::Integration::TestUnit < Evilution::Integration::Base
|
|
|
89
92
|
end
|
|
90
93
|
|
|
91
94
|
def execute_test_unit(files, command)
|
|
92
|
-
new_classes =
|
|
93
|
-
files.each { |f| load(File.expand_path(f, Evilution.project_base_dir)) }
|
|
94
|
-
end
|
|
95
|
+
new_classes = load_test_classes(files)
|
|
95
96
|
return ResultBuilder.no_tests_ran(command) if Dispatcher.test_method_count(new_classes).zero?
|
|
96
97
|
|
|
97
98
|
detector = reset_crash_detector
|
|
@@ -100,6 +101,13 @@ class Evilution::Integration::TestUnit < Evilution::Integration::Base
|
|
|
100
101
|
ResultBuilder.call(passed: result.passed?, command: command, detector: detector)
|
|
101
102
|
end
|
|
102
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
|
+
|
|
103
111
|
# Test::Unit has no public registry-clear analogous to
|
|
104
112
|
# Minitest::Runnable.runnables.clear. SubjectClassRegistry's newly_loaded
|
|
105
113
|
# block scopes each dispatch to classes loaded in *this* round, so stale
|