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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +28 -0
  3. data/.rubocop_todo.yml +1 -0
  4. data/CHANGELOG.md +31 -0
  5. data/README.md +12 -10
  6. data/docs/integrations.md +15 -0
  7. data/docs/isolation.md +46 -2
  8. data/lib/evilution/baseline.rb +11 -4
  9. data/lib/evilution/cli/parser/options_builder.rb +17 -0
  10. data/lib/evilution/config/validators/example_targeting_strategy.rb +22 -0
  11. data/lib/evilution/config.rb +16 -2
  12. data/lib/evilution/coverage/digest.rb +16 -0
  13. data/lib/evilution/coverage/map.rb +64 -0
  14. data/lib/evilution/coverage/map_builder.rb +82 -0
  15. data/lib/evilution/coverage/map_store.rb +87 -0
  16. data/lib/evilution/coverage/recorder.rb +85 -0
  17. data/lib/evilution/coverage.rb +8 -0
  18. data/lib/evilution/coverage_example_filter.rb +41 -0
  19. data/lib/evilution/integration/loading/test_load_path.rb +76 -0
  20. data/lib/evilution/integration/minitest.rb +5 -1
  21. data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +72 -0
  22. data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +45 -0
  23. data/lib/evilution/integration/rspec/state_guard.rb +3 -1
  24. data/lib/evilution/integration/test_unit.rb +12 -4
  25. data/lib/evilution/isolation/fork.rb +38 -50
  26. data/lib/evilution/parallel/work_queue/dispatcher/deadline_tracker.rb +63 -0
  27. data/lib/evilution/parallel/work_queue/dispatcher.rb +70 -25
  28. data/lib/evilution/parallel/work_queue/worker.rb +50 -14
  29. data/lib/evilution/parallel/work_queue.rb +8 -0
  30. data/lib/evilution/process_supervisor.rb +259 -0
  31. data/lib/evilution/reporter/cli/line_formatters/unresolved_rate_warning.rb +50 -0
  32. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  33. data/lib/evilution/runner/baseline_runner.rb +52 -0
  34. data/lib/evilution/runner/isolation_resolver.rb +106 -12
  35. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +28 -1
  36. data/lib/evilution/runner.rb +7 -0
  37. data/lib/evilution/spec_resolver.rb +147 -9
  38. data/lib/evilution/spec_selector.rb +14 -4
  39. data/lib/evilution/version.rb +1 -1
  40. data/lib/evilution.rb +1 -0
  41. data/lib/tasks/stress.rake +15 -0
  42. data/scripts/canary_manifest.yml +47 -0
  43. data/scripts/compare_targeting +277 -0
  44. data/scripts/compare_targeting.example.yml +24 -0
  45. 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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../evilution"
4
+
5
+ # Per-example line-coverage support: build a `source file:line -> [examples]`
6
+ # map so mutation targeting can run exactly the examples that execute a line.
7
+ module Evilution::Coverage
8
+ 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).each { |f| load(File.expand_path(f)) }
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
- baseline_test_files(test_file).each { |f| load(File.expand_path(f)) }
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 = SubjectClassRegistry.newly_loaded do
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