saboteur 0.3.0 → 0.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/CHANGELOG.md +113 -1
- data/README.md +248 -63
- data/lib/generators/saboteur/install/install_generator.rb +21 -0
- data/lib/saboteur/agent_skill.rb +40 -0
- data/lib/saboteur/cli.rb +150 -22
- data/lib/saboteur/config.rb +27 -15
- data/lib/saboteur/config_file.rb +97 -0
- data/lib/saboteur/custom_map.rb +170 -0
- data/lib/saboteur/doctor.rb +17 -1
- data/lib/saboteur/engine/baseline_guard.rb +20 -3
- data/lib/saboteur/engine/boot.rb +9 -0
- data/lib/saboteur/engine/coverage_store.rb +31 -4
- data/lib/saboteur/engine/data_inputs.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +28 -0
- data/lib/saboteur/engine/diff_scope.rb +8 -0
- data/lib/saboteur/engine/run_fingerprint.rb +34 -6
- data/lib/saboteur/engine/scheduler.rb +7 -16
- data/lib/saboteur/engine/suite_files.rb +8 -1
- data/lib/saboteur/engine.rb +74 -13
- data/lib/saboteur/execution/reevaluator.rb +7 -4
- data/lib/saboteur/execution/supervisor.rb +23 -11
- data/lib/saboteur/frameworks/rails.rb +329 -13
- data/lib/saboteur/help.rb +33 -17
- data/lib/saboteur/ignore_list.rb +71 -17
- data/lib/saboteur/install.rb +132 -0
- data/lib/saboteur/integration/rspec.rb +47 -3
- data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
- data/lib/saboteur/mutation_source/builtin.rb +8 -2
- data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
- data/lib/saboteur/replay.rb +1 -1
- data/lib/saboteur/reporter/agent.rb +87 -0
- data/lib/saboteur/reporter/agent_clipping.rb +29 -0
- data/lib/saboteur/reporter/agent_commands.rb +61 -0
- data/lib/saboteur/reporter/agent_incomplete.rb +37 -0
- data/lib/saboteur/reporter/agent_summary.rb +131 -0
- data/lib/saboteur/reporter/findings.rb +8 -0
- data/lib/saboteur/reporter/html.rb +49 -1
- data/lib/saboteur/reporter/stryker.rb +11 -4
- data/lib/saboteur/reporter/terminal.rb +65 -2
- data/lib/saboteur/result.rb +46 -5
- data/lib/saboteur/score.rb +89 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +48 -0
- data/lib/saboteur.rb +4 -0
- metadata +17 -2
|
@@ -45,15 +45,32 @@ module Saboteur
|
|
|
45
45
|
|
|
46
46
|
def check(number)
|
|
47
47
|
Execution::ForkReadiness.prepare_heap
|
|
48
|
-
|
|
48
|
+
heartbeat = @diagnostics.progress_heartbeat("baseline check #{number}")
|
|
49
|
+
result = @supervisor.run(timeout: @config["coverage_timeout"], on_tick: heartbeat.method(:tick)) do |writer|
|
|
50
|
+
record(writer, number)
|
|
51
|
+
end
|
|
49
52
|
refuse(number, "the suite stopped: #{result.stopped_by} (#{result.detail})") if result.stopped_by
|
|
53
|
+
# Before the payload checks: a child killed by a signal (the OOM
|
|
54
|
+
# killer, say) writes nothing, and the signal is the explanation.
|
|
55
|
+
if result.exit_status.is_a?(Process::Status) && !result.exit_status.success?
|
|
56
|
+
refuse(number, unsuccessful_exit(result.exit_status))
|
|
57
|
+
end
|
|
50
58
|
refuse(number, "the child did not report a result") if result.payload.empty?
|
|
51
|
-
refuse(number, "the child exited unsuccessfully") if
|
|
52
|
-
result.exit_status.is_a?(Process::Status) && !result.exit_status.success?
|
|
53
59
|
refuse(number, "the suite left child processes behind") if result.leftover_pids.any?
|
|
54
60
|
parse_result(number, result.payload)
|
|
55
61
|
end
|
|
56
62
|
|
|
63
|
+
# A signaled child (the OOM killer, say) can leave an empty log,
|
|
64
|
+
# which reads like a suite failure; name the signal and hint the
|
|
65
|
+
# likely cause instead.
|
|
66
|
+
def unsuccessful_exit(status)
|
|
67
|
+
return "the child exited with status #{status.exitstatus}" unless status.signaled?
|
|
68
|
+
|
|
69
|
+
signal = "SIG#{Signal.signame(status.termsig)}"
|
|
70
|
+
hint = signal == "SIGKILL" ? " - possible OOM, check memory" : ""
|
|
71
|
+
"the child was killed by #{signal}#{hint}"
|
|
72
|
+
end
|
|
73
|
+
|
|
57
74
|
def parse_result(number, payload)
|
|
58
75
|
data = JSON.parse(payload)
|
|
59
76
|
refuse(number, "the child reported an invalid result") unless data.is_a?(Hash)
|
data/lib/saboteur/engine/boot.rb
CHANGED
|
@@ -62,6 +62,7 @@ module Saboteur
|
|
|
62
62
|
@rails = rails = Frameworks::Rails.new(@config, @hooks) if @config["rails"]
|
|
63
63
|
rails&.before_boot
|
|
64
64
|
@hooks.run(:before_boot)
|
|
65
|
+
add_spec_to_load_path if @config["rails"]
|
|
65
66
|
@config["require"].each { |file| require File.expand_path(file, @root) }
|
|
66
67
|
integration = Integration.fetch(@config["integration"]).new(@config)
|
|
67
68
|
integration.setup
|
|
@@ -72,6 +73,14 @@ module Saboteur
|
|
|
72
73
|
|
|
73
74
|
private
|
|
74
75
|
|
|
76
|
+
# rspec puts spec/ on the load path, so a boot file under spec/ (a
|
|
77
|
+
# dummy app's rails_helper, say) can require its siblings. Match
|
|
78
|
+
# that, or the same require list fails to load.
|
|
79
|
+
def add_spec_to_load_path
|
|
80
|
+
path = File.join(@root, "spec")
|
|
81
|
+
$LOAD_PATH.unshift(path) unless $LOAD_PATH.include?(path)
|
|
82
|
+
end
|
|
83
|
+
|
|
75
84
|
def load_hooks
|
|
76
85
|
Saboteur.current_hooks = @hooks
|
|
77
86
|
@config["hooks"].each { |file| load File.expand_path(file, @root) }
|
|
@@ -27,9 +27,9 @@ module Saboteur
|
|
|
27
27
|
def fetch(files)
|
|
28
28
|
path = File.join(@config.path("cache_dir"), "coverage-#{cache_key(files)}.json")
|
|
29
29
|
cached = @config["cache"] ? Selection::CoverageMap.load(path) : nil
|
|
30
|
-
return cached if cached
|
|
30
|
+
return refuse_empty_map(cached, files) if cached
|
|
31
31
|
|
|
32
|
-
map = build(files)
|
|
32
|
+
map = refuse_empty_map(build(files), files)
|
|
33
33
|
map.save(path)
|
|
34
34
|
map
|
|
35
35
|
end
|
|
@@ -52,11 +52,15 @@ module Saboteur
|
|
|
52
52
|
"Run through the saboteur executable and disable SimpleCov for the run."
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
@
|
|
55
|
+
scope = @config["tests"].empty? ? "the whole suite" : "the selected specs"
|
|
56
|
+
@diagnostics.inform("building coverage map (runs #{scope} once)")
|
|
56
57
|
# The suite child forked next inherits this heap; pack it first, to
|
|
57
58
|
# reduce later copying of shared pages.
|
|
58
59
|
Execution::ForkReadiness.prepare_heap
|
|
59
|
-
|
|
60
|
+
heartbeat = @diagnostics.progress_heartbeat("building coverage map")
|
|
61
|
+
result = @supervisor.run(timeout: @config["coverage_timeout"], on_tick: heartbeat.method(:tick)) do |writer|
|
|
62
|
+
record(writer, files)
|
|
63
|
+
end
|
|
60
64
|
raise Error, "coverage map build stopped: #{result.detail}" if result.stopped_by
|
|
61
65
|
raise Error, "coverage map build failed: #{died(result.exit_status)}" if result.payload.empty?
|
|
62
66
|
|
|
@@ -66,6 +70,29 @@ module Saboteur
|
|
|
66
70
|
Selection::CoverageMap.from_json(result.payload)
|
|
67
71
|
end
|
|
68
72
|
|
|
73
|
+
# A whole-suite build that records no line in any included file fails
|
|
74
|
+
# the run (exit 3): usually the setup hid the app from Coverage
|
|
75
|
+
# (Bootsnap's compile cache is the default Rails case); it can also
|
|
76
|
+
# mean no test in the suite exercises any included file. Either way a
|
|
77
|
+
# run on this map would test nothing, and exit 0 would lie. With
|
|
78
|
+
# explicit tests (--test) an empty map is a legitimate outcome
|
|
79
|
+
# instead: the user narrowed the suite to specs that may not touch
|
|
80
|
+
# these sources, so the map passes with a warning. Applies to a cached
|
|
81
|
+
# map too, so a map cached before this check cannot keep a run green,
|
|
82
|
+
# and the guard runs before saving, so an empty map is never cached.
|
|
83
|
+
def refuse_empty_map(map, files)
|
|
84
|
+
return map if files.empty? || map.lines.any? { |_file, by_line| by_line.any? }
|
|
85
|
+
|
|
86
|
+
if @config["tests"].empty?
|
|
87
|
+
raise Error, "Coverage recorded no lines in #{files.size} included files during a whole-suite " \
|
|
88
|
+
"build. Is Bootsnap's compile cache on? If not, no test may exercise these files. " \
|
|
89
|
+
"Run `saboteur doctor`."
|
|
90
|
+
end
|
|
91
|
+
@diagnostics.warn("Coverage recorded no lines in #{files.size} included files; " \
|
|
92
|
+
"the selected specs may not exercise them.")
|
|
93
|
+
map
|
|
94
|
+
end
|
|
95
|
+
|
|
69
96
|
def died(status)
|
|
70
97
|
how = if status&.signaled? then "was killed by signal #{Signal.signame(status.termsig)}"
|
|
71
98
|
else "exited with status #{status&.exitstatus}"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# Non-Ruby files a test run can read and that change its outcome: Rails
|
|
7
|
+
# fixtures, schema dumps and database/environment configuration, plus
|
|
8
|
+
# anything listed under `fingerprint_extra`. Ruby files are tracked
|
|
9
|
+
# elsewhere; these have no .rb extension, so the Ruby walk misses them.
|
|
10
|
+
# An absent pattern simply matches nothing, so plain gems are unaffected.
|
|
11
|
+
module DataInputs
|
|
12
|
+
GLOBS = %w[
|
|
13
|
+
spec/fixtures/**/*
|
|
14
|
+
spec/**/fixtures/**/*
|
|
15
|
+
test/fixtures/**/*
|
|
16
|
+
db/structure.sql
|
|
17
|
+
db/schema.rb
|
|
18
|
+
config/database.yml
|
|
19
|
+
config/database.yml.erb
|
|
20
|
+
config/*.yml
|
|
21
|
+
config/environments/*.yml
|
|
22
|
+
config/credentials/*.yml.enc
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
# True when the absolute path is one of the declared inputs, whether or
|
|
26
|
+
# not the file exists yet. A run must never write there.
|
|
27
|
+
def self.match?(root, path, extra: [])
|
|
28
|
+
return false unless path.start_with?("#{root}/")
|
|
29
|
+
|
|
30
|
+
relative = path.delete_prefix("#{root}/")
|
|
31
|
+
flags = File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB
|
|
32
|
+
(GLOBS + extra).any? { |pattern| File.fnmatch?(pattern, relative, flags) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Sorted absolute paths of regular files (symlinks followed) under root.
|
|
36
|
+
def self.files(root, extra: [])
|
|
37
|
+
(GLOBS + extra).flat_map { |pattern| Dir.glob(pattern, File::FNM_DOTMATCH, base: root) }
|
|
38
|
+
.map { |relative| File.join(root, relative) }
|
|
39
|
+
.select { |path| File.file?(path) }.uniq.sort
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -6,6 +6,26 @@ module Saboteur
|
|
|
6
6
|
# What a run has to say besides results: warnings for the report, the
|
|
7
7
|
# count of mutations dropped for not parsing, and progress marks.
|
|
8
8
|
class Diagnostics
|
|
9
|
+
# See progress_heartbeat. Time-gated itself, so the supervisor can
|
|
10
|
+
# call tick on every poll.
|
|
11
|
+
class ProgressHeartbeat
|
|
12
|
+
def initialize(diagnostics, label, interval: 30)
|
|
13
|
+
@diagnostics = diagnostics
|
|
14
|
+
@label = label
|
|
15
|
+
@interval = interval
|
|
16
|
+
@started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
17
|
+
@last_print = @started
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def tick
|
|
21
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
22
|
+
return if now - @last_print < @interval
|
|
23
|
+
|
|
24
|
+
@last_print = now
|
|
25
|
+
@diagnostics.inform("#{@label}: #{(now - @started).round} s elapsed")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
9
29
|
MARKS = { killed: ".", survived: "S", timeout: "T", resource_limit: "L", error: "E" }.freeze
|
|
10
30
|
|
|
11
31
|
attr_reader :warnings, :invalid_mutations
|
|
@@ -28,6 +48,14 @@ module Saboteur
|
|
|
28
48
|
@err.puts("saboteur: #{message}")
|
|
29
49
|
end
|
|
30
50
|
|
|
51
|
+
# A heartbeat for a long phase: the supervisor's wait loop calls tick
|
|
52
|
+
# in the parent after the fork, so no extra thread is ever alive at
|
|
53
|
+
# fork time. Prints "<label>: N s elapsed" about every interval
|
|
54
|
+
# seconds, so a long phase does not look stuck.
|
|
55
|
+
def progress_heartbeat(label, interval: 30)
|
|
56
|
+
ProgressHeartbeat.new(self, label, interval: interval)
|
|
57
|
+
end
|
|
58
|
+
|
|
31
59
|
def progress(result)
|
|
32
60
|
@err.print(MARKS.fetch(result.status, "?"))
|
|
33
61
|
@err.flush
|
|
@@ -20,6 +20,14 @@ module Saboteur
|
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# The changed lines a mutant touches, sorted. A statement that spans
|
|
24
|
+
# several lines is mutated as one piece and reported at its first line,
|
|
25
|
+
# which can sit above the line that changed; this names the changed line.
|
|
26
|
+
def changed_lines_for(subject, mutation)
|
|
27
|
+
changed = @changed_lines.fetch(subject.file, Set.new)
|
|
28
|
+
mutation.edits.flat_map { |edit| edited_lines(subject, edit).to_a }.uniq.select { changed.include?(_1) }.sort
|
|
29
|
+
end
|
|
30
|
+
|
|
23
31
|
private
|
|
24
32
|
|
|
25
33
|
def edited_lines(subject, edit)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
require "digest"
|
|
5
5
|
require "json"
|
|
6
|
+
require_relative "data_inputs"
|
|
6
7
|
|
|
7
8
|
module Saboteur
|
|
8
9
|
class Engine
|
|
@@ -12,7 +13,7 @@ module Saboteur
|
|
|
12
13
|
FORMAT_VERSION = 1
|
|
13
14
|
MANIFESTS = %w[Gemfile gems.rb Gemfile.lock gems.locked .rspec .rspec-local].freeze
|
|
14
15
|
LOCATION_SETTINGS = %w[cache_dir json_report html_report].freeze
|
|
15
|
-
ENTRY_KINDS = %w[ruby manifest boot hook source_plugin extra].freeze
|
|
16
|
+
ENTRY_KINDS = %w[ruby manifest boot hook source_plugin extra data].freeze
|
|
16
17
|
EXCLUDED_DIRECTORIES = %w[.git vendor node_modules].freeze
|
|
17
18
|
Entry = Data.define(:path, :kind, :state, :sha256, :required)
|
|
18
19
|
Change = Data.define(:path, :reason)
|
|
@@ -178,11 +179,12 @@ module Saboteur
|
|
|
178
179
|
end
|
|
179
180
|
|
|
180
181
|
def output_overlap_issues
|
|
181
|
-
paths = required_files.map(&:first) + MANIFESTS.map { |name| File.join(@root, name) }
|
|
182
|
+
paths = required_files.map(&:first) + MANIFESTS.map { |name| File.join(@root, name) } +
|
|
183
|
+
DataInputs.files(@root, extra: @config["fingerprint_extra"])
|
|
182
184
|
outputs = %w[json_report html_report].filter_map { |key| @config.fetch(key) }
|
|
183
185
|
.map { |path| canonical_path(path) }
|
|
184
186
|
issues = outputs.filter_map do |path|
|
|
185
|
-
if path.end_with?(".rb") || paths.include?(path) || File.directory?(path)
|
|
187
|
+
if path.end_with?(".rb") || paths.include?(path) || File.directory?(path) || data_input?(path)
|
|
186
188
|
"output overlaps input: #{display_path(path)}"
|
|
187
189
|
end
|
|
188
190
|
end
|
|
@@ -192,24 +194,50 @@ module Saboteur
|
|
|
192
194
|
|
|
193
195
|
def cache_overlap_issues(paths)
|
|
194
196
|
cache = canonical_path(@config["cache_dir"])
|
|
195
|
-
protected = [@root] + %w[app lib spec].map { |name| File.join(@root, name) } + paths
|
|
196
|
-
|
|
197
|
+
protected = [@root] + %w[app lib spec test/fixtures].map { |name| File.join(@root, name) } + paths
|
|
198
|
+
inside = protected.any? do |path|
|
|
197
199
|
path == cache || path.start_with?("#{cache}/") || (path != @root && cache.start_with?("#{path}/"))
|
|
198
200
|
end
|
|
201
|
+
overlaps = inside || data_input?(cache)
|
|
199
202
|
overlaps ? ["cache directory overlaps inputs: #{display_path(cache)}"] : []
|
|
200
203
|
end
|
|
201
204
|
|
|
205
|
+
# Declared fixture, schema or config input, or a path inside a fixture tree.
|
|
206
|
+
def data_input?(path)
|
|
207
|
+
DataInputs.match?(@root, path, extra: @config["fingerprint_extra"])
|
|
208
|
+
end
|
|
209
|
+
|
|
202
210
|
def inventory(issues)
|
|
203
211
|
paths = project_files(issues).to_h { |path| [path, "ruby"] }
|
|
204
212
|
MANIFESTS.each { |name| paths[File.join(@root, name)] = "manifest" }
|
|
213
|
+
data_files(issues).each { |path| paths[path] ||= "data" }
|
|
205
214
|
required = merged_required_files
|
|
206
215
|
required.each { |path, (kind, _)| paths[path] = kind }
|
|
207
216
|
paths.map do |path, kind|
|
|
208
|
-
needed = required.key?(path) ? required[path].last : kind
|
|
217
|
+
needed = required.key?(path) ? required[path].last : !%w[manifest data].include?(kind)
|
|
209
218
|
read_entry(path, kind, needed, issues)
|
|
210
219
|
end
|
|
211
220
|
end
|
|
212
221
|
|
|
222
|
+
# Fixtures, schema dumps and database config. A pattern with no match is
|
|
223
|
+
# fine; a listed file that vanishes later shows up as "removed". Like Ruby
|
|
224
|
+
# files, data reached through a symlink (the file or a directory above it)
|
|
225
|
+
# is an undeclared boundary: its real path could be an output or lie outside
|
|
226
|
+
# the project, so the snapshot is incomplete instead of guessing.
|
|
227
|
+
def data_files(issues)
|
|
228
|
+
DataInputs.files(@root, extra: @config["fingerprint_extra"]).reject do |path|
|
|
229
|
+
next false unless symlinked?(path)
|
|
230
|
+
|
|
231
|
+
issues << "symlink input: #{display_path(path)}".freeze
|
|
232
|
+
true
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def symlinked?(path)
|
|
237
|
+
current = @root
|
|
238
|
+
path.delete_prefix("#{@root}/").split("/").any? { |part| File.symlink?(current = File.join(current, part)) }
|
|
239
|
+
end
|
|
240
|
+
|
|
213
241
|
def merged_required_files
|
|
214
242
|
required_files.group_by(&:first).transform_values do |entries|
|
|
215
243
|
roles = entries.map { |_, kind, _| kind }
|
|
@@ -7,11 +7,10 @@ module Saboteur
|
|
|
7
7
|
# unit of work parallel workers will share out.
|
|
8
8
|
#
|
|
9
9
|
# Order of business: skip what cannot be run (refinements, not loaded,
|
|
10
|
-
# shadowed, no covering test); coarse mutants; fine mutants
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# tests' recorded durations.
|
|
10
|
+
# shadowed, no covering test); coarse mutants; all eligible fine mutants;
|
|
11
|
+
# then check every kill and timeout against the unchanged method
|
|
12
|
+
# (VerdictCheck). Each mutant runs only the tests that executed its edit,
|
|
13
|
+
# likeliest killers first, with a timeout from their recorded durations.
|
|
15
14
|
class Scheduler
|
|
16
15
|
# A subject to run, with what its mutants need. killers counts the
|
|
17
16
|
# tests that killed earlier mutants of this subject; slowdown is how
|
|
@@ -45,7 +44,7 @@ module Saboteur
|
|
|
45
44
|
reused = all_reused(plan, candidates)
|
|
46
45
|
return reused if reused
|
|
47
46
|
|
|
48
|
-
results = run_candidates(
|
|
47
|
+
results = run_candidates(candidates) do |mutation, text|
|
|
49
48
|
reuse(plan, mutation, text) || execute(plan, mutation, text)
|
|
50
49
|
end
|
|
51
50
|
checked(subject, results)
|
|
@@ -86,21 +85,13 @@ module Saboteur
|
|
|
86
85
|
return nil unless @cache
|
|
87
86
|
|
|
88
87
|
catch(:must_run) do
|
|
89
|
-
run_candidates(
|
|
88
|
+
run_candidates(candidates) { |mutation, text| reuse(plan, mutation, text) || throw(:must_run) }
|
|
90
89
|
end
|
|
91
90
|
end
|
|
92
91
|
|
|
93
|
-
def run_candidates(
|
|
92
|
+
def run_candidates(candidates, &run_one)
|
|
94
93
|
coarse, fine = candidates.partition { |mutation, _| mutation.coarse }
|
|
95
94
|
results = coarse.map { |mutation, text| run_one.call(mutation, text) }
|
|
96
|
-
# A surviving coarse mutant means nothing checks the body, so the fine
|
|
97
|
-
# mutants would only add noise. Not so when a rescue or else can
|
|
98
|
-
# return a value of its own: fine mutants that make the body raise
|
|
99
|
-
# can still be caught.
|
|
100
|
-
if results.any?(&:survived?) && !plan.subject.recovers?
|
|
101
|
-
return results + skip_all(fine, plan.subject, "coarse mutant survived", covered_by: plan.tests)
|
|
102
|
-
end
|
|
103
|
-
|
|
104
95
|
results + fine.map { |mutation, text| run_one.call(mutation, text) }
|
|
105
96
|
end
|
|
106
97
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
require "digest"
|
|
5
5
|
require "shellwords"
|
|
6
|
+
require_relative "data_inputs"
|
|
6
7
|
|
|
7
8
|
module Saboteur
|
|
8
9
|
class Engine
|
|
@@ -22,7 +23,7 @@ module Saboteur
|
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
def files
|
|
25
|
-
(shared_spec_files + boot_and_hook_files + option_files + required_by_options).uniq.sort
|
|
26
|
+
(shared_spec_files + boot_and_hook_files + option_files + required_by_options + data_files).uniq.sort
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def digest
|
|
@@ -33,6 +34,12 @@ module Saboteur
|
|
|
33
34
|
|
|
34
35
|
private
|
|
35
36
|
|
|
37
|
+
# Fixtures, schema dumps and database config: not Ruby, but they change
|
|
38
|
+
# what the tests see, so a stale cache must not survive an edit to them.
|
|
39
|
+
def data_files
|
|
40
|
+
DataInputs.files(@root, extra: @config["fingerprint_extra"])
|
|
41
|
+
end
|
|
42
|
+
|
|
36
43
|
def shared_spec_files
|
|
37
44
|
Dir.glob("spec/**/*.rb", base: @root).reject { |file| file.end_with?("_spec.rb") }
|
|
38
45
|
.map { |file| File.join(@root, file) }
|
data/lib/saboteur/engine.rb
CHANGED
|
@@ -29,20 +29,52 @@ module Saboteur
|
|
|
29
29
|
Boot.refuse_recursion!
|
|
30
30
|
started = monotonic
|
|
31
31
|
ignore_list = @replay ? IgnoreList.new([]) : IgnoreList.load(config.path("ignore_file"))
|
|
32
|
+
@diagnostics.warnings.concat(custom_map_warnings(load_custom_map))
|
|
32
33
|
boot = Boot.new(config, hooks)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
34
|
+
begin
|
|
35
|
+
integration = boot.load_target
|
|
36
|
+
@rails = boot.rails
|
|
37
|
+
files = config.included_files
|
|
38
|
+
subjects, scope = select_subjects(files)
|
|
39
|
+
results = subjects.empty? ? [] : mutate(subjects, files, integration, scope)
|
|
40
|
+
@diagnostics.warnings.concat(ignore_list.apply(results))
|
|
41
|
+
build_report(results, subjects, scope, started)
|
|
42
|
+
ensure
|
|
43
|
+
# The run boundary for the per-worker databases: cleanup runs however
|
|
44
|
+
# the call ends - a boot that raised partway through, a baseline or
|
|
45
|
+
# coverage failure, no subjects, no runnable work - so a host that
|
|
46
|
+
# embeds Engine and calls it repeatedly never holds copies or
|
|
47
|
+
# snapshots until process exit. cleanup_workers is idempotent; the
|
|
48
|
+
# Rails profile's at_exit handler stays as the fallback for an
|
|
49
|
+
# abrupt process exit.
|
|
50
|
+
safely_cleanup_workers(boot.rails)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Stale entries and duplicate literals never fail a run; they warn so the
|
|
55
|
+
# map does not rot silently. doctor fails on stale keys.
|
|
56
|
+
def load_custom_map
|
|
57
|
+
@replay ? CustomMap.new([]) : CustomMap.load(config.path("custom_map"), root: config.root)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def custom_map_warnings(map)
|
|
61
|
+
path = config.path("custom_map")
|
|
62
|
+
warnings = map.stale_keys.map { |key| "stale custom map key #{key}: matches no file" }
|
|
63
|
+
return warnings unless path && File.file?(path)
|
|
64
|
+
|
|
65
|
+
warnings + map.duplicate_keys(path).map { |key| "duplicate custom map key #{key}: Ruby keeps the last" }
|
|
42
66
|
end
|
|
43
67
|
|
|
44
68
|
private
|
|
45
69
|
|
|
70
|
+
# The run's immutable report, built once the last result is in.
|
|
71
|
+
def build_report(results, subjects, scope, started)
|
|
72
|
+
Report.new(results: results, subjects: subjects, wall_time: monotonic - started, root: @root,
|
|
73
|
+
invalid_mutations: @diagnostics.invalid_mutations, warnings: @diagnostics.warnings,
|
|
74
|
+
review: !scope.nil?, seed: config["seed"], skipped_subjects: @skipped_subjects,
|
|
75
|
+
diff_scope: scope)
|
|
76
|
+
end
|
|
77
|
+
|
|
46
78
|
# The subjects to mutate and, in diff mode, the scope of changed lines.
|
|
47
79
|
def select_subjects(files)
|
|
48
80
|
config.unmatched_patterns.each { |pattern| @diagnostics.warn("#{pattern} matches no file") }
|
|
@@ -55,6 +87,7 @@ module Saboteur
|
|
|
55
87
|
end
|
|
56
88
|
|
|
57
89
|
def mutate(subjects, files, integration, scope)
|
|
90
|
+
warn_about_unscoped_suite(integration)
|
|
58
91
|
check_baseline(integration)
|
|
59
92
|
map = CoverageStore.new(config: config, integration: integration, hooks: hooks,
|
|
60
93
|
supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).fetch(files)
|
|
@@ -72,7 +105,8 @@ module Saboteur
|
|
|
72
105
|
# are retained, to reduce later copying of shared pages.
|
|
73
106
|
Execution::ForkReadiness.prepare_heap
|
|
74
107
|
results = Distribution.new(jobs: config["jobs"], scheduler: scheduler, diagnostics: @diagnostics,
|
|
75
|
-
cache: cache, on_start: method(:start_worker)).run(runnable_subjects,
|
|
108
|
+
cache: cache, on_start: method(:start_worker)).run(runnable_subjects,
|
|
109
|
+
runnable_lists)
|
|
76
110
|
cache&.save
|
|
77
111
|
results
|
|
78
112
|
end
|
|
@@ -82,6 +116,18 @@ module Saboteur
|
|
|
82
116
|
warn_about_threads
|
|
83
117
|
end
|
|
84
118
|
|
|
119
|
+
# A --since run without --test runs the loaded suite twice for the
|
|
120
|
+
# baselines; a cold coverage cache adds one more pass. Say so
|
|
121
|
+
# before those hours pass, with the way to scope it.
|
|
122
|
+
def warn_about_unscoped_suite(integration)
|
|
123
|
+
return unless config["since"] && config["tests"].empty?
|
|
124
|
+
|
|
125
|
+
message = "running the loaded suite unscoped: two baseline passes; coverage may add a pass " \
|
|
126
|
+
"(#{integration.test_files.size} spec files). Pass --test to scope it."
|
|
127
|
+
@diagnostics.warn(message)
|
|
128
|
+
@diagnostics.inform(message)
|
|
129
|
+
end
|
|
130
|
+
|
|
85
131
|
def check_baseline(integration)
|
|
86
132
|
BaselineGuard.new(config: config, integration: integration, hooks: hooks,
|
|
87
133
|
supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).call
|
|
@@ -104,6 +150,14 @@ module Saboteur
|
|
|
104
150
|
suite_digest: SuiteFiles.new(config).digest)
|
|
105
151
|
end
|
|
106
152
|
|
|
153
|
+
# A cleanup failure must never mask the run's own outcome - not even
|
|
154
|
+
# one cleanup_workers did not anticipate.
|
|
155
|
+
def safely_cleanup_workers(rails)
|
|
156
|
+
rails&.cleanup_workers
|
|
157
|
+
rescue StandardError => e
|
|
158
|
+
@diagnostics.warn(e.message)
|
|
159
|
+
end
|
|
160
|
+
|
|
107
161
|
# In each worker: its own database, then the user's worker_start hooks.
|
|
108
162
|
def start_worker(number)
|
|
109
163
|
@rails&.start_worker(number)
|
|
@@ -143,10 +197,17 @@ module Saboteur
|
|
|
143
197
|
end
|
|
144
198
|
end
|
|
145
199
|
|
|
200
|
+
# Threads do not survive fork, so name them: with the name the owner
|
|
201
|
+
# gave the thread, else the first frame of its backtrace, so the user
|
|
202
|
+
# can tell whether they matter (a connection pool reaper, Sidekiq, an
|
|
203
|
+
# SDK's background flusher).
|
|
146
204
|
def warn_about_threads
|
|
147
|
-
others = Thread.list
|
|
148
|
-
|
|
149
|
-
|
|
205
|
+
others = Thread.list - [Thread.current]
|
|
206
|
+
return if others.empty?
|
|
207
|
+
|
|
208
|
+
names = others.map { |thread| thread.name || thread.backtrace&.first || "unknown" }
|
|
209
|
+
list = names.join(", ")
|
|
210
|
+
@diagnostics.warn("#{others.size} other thread(s) alive at fork time; they do not run in children: #{list}")
|
|
150
211
|
end
|
|
151
212
|
|
|
152
213
|
def monotonic
|
|
@@ -29,11 +29,12 @@ module Saboteur
|
|
|
29
29
|
target = subject.singleton ? owner.singleton_class : owner
|
|
30
30
|
visibility = visibility_of(target, subject.name)
|
|
31
31
|
module_function = module_function?(owner, subject)
|
|
32
|
+
copy_visibility = visibility_of(owner.singleton_class, subject.name) if module_function
|
|
32
33
|
aliases = aliases_of(target, subject.name)
|
|
33
34
|
|
|
34
35
|
@strategy == "nesting" ? eval_nested(subject, text) : eval_in_owner(owner, subject, text)
|
|
35
36
|
|
|
36
|
-
restore(owner, target, subject.name, visibility,
|
|
37
|
+
restore(owner, target, subject.name, visibility, copy_visibility)
|
|
37
38
|
repoint(target, subject.name, aliases)
|
|
38
39
|
end
|
|
39
40
|
|
|
@@ -82,7 +83,8 @@ module Saboteur
|
|
|
82
83
|
def module_function?(owner, subject)
|
|
83
84
|
return false if subject.singleton || owner.is_a?(Class)
|
|
84
85
|
return false unless owner.private_method_defined?(subject.name, false)
|
|
85
|
-
return false unless owner.singleton_class.
|
|
86
|
+
return false unless owner.singleton_class.method_defined?(subject.name, false) ||
|
|
87
|
+
owner.singleton_class.private_method_defined?(subject.name, false)
|
|
86
88
|
|
|
87
89
|
owner.instance_method(subject.name).source_location ==
|
|
88
90
|
owner.singleton_class.instance_method(subject.name).source_location
|
|
@@ -132,9 +134,10 @@ module Saboteur
|
|
|
132
134
|
end
|
|
133
135
|
end
|
|
134
136
|
|
|
135
|
-
def restore(owner, target, name, visibility,
|
|
136
|
-
if
|
|
137
|
+
def restore(owner, target, name, visibility, copy_visibility)
|
|
138
|
+
if copy_visibility
|
|
137
139
|
owner.send(:module_function, name)
|
|
140
|
+
owner.singleton_class.send(copy_visibility, name)
|
|
138
141
|
elsif visibility != :public
|
|
139
142
|
target.send(visibility, name)
|
|
140
143
|
end
|
|
@@ -94,7 +94,10 @@ module Saboteur
|
|
|
94
94
|
# Runs the block in a forked child with a pipe writer; the child exits
|
|
95
95
|
# when the block returns. Returns a Result. cpu_seconds bounds every
|
|
96
96
|
# descendant's CPU time (default: twice the deadline, at least 10 s).
|
|
97
|
-
|
|
97
|
+
# on_tick is called from the parent's wait loop after the fork, about
|
|
98
|
+
# every poll, so callers can report progress without a thread alive
|
|
99
|
+
# at fork time.
|
|
100
|
+
def run(timeout:, cpu_seconds: nil, on_tick: nil, &block)
|
|
98
101
|
depth = ENV.fetch(CHILD_ENV, "0").to_i
|
|
99
102
|
raise Error, "refusing to fork: saboteur children nested #{depth} deep (limit #{MAX_DEPTH})" if
|
|
100
103
|
depth >= MAX_DEPTH
|
|
@@ -107,7 +110,7 @@ module Saboteur
|
|
|
107
110
|
supervisor = Process.pid
|
|
108
111
|
pid = fork { child(reader, writer, supervisor, cpu_seconds, depth + 1, &block) }
|
|
109
112
|
writer.close
|
|
110
|
-
contain(pid, reader, started, timeout)
|
|
113
|
+
contain(pid, reader, started, timeout, on_tick)
|
|
111
114
|
ensure
|
|
112
115
|
reader&.close unless reader&.closed?
|
|
113
116
|
end
|
|
@@ -168,24 +171,31 @@ module Saboteur
|
|
|
168
171
|
|
|
169
172
|
# --- parent ---------------------------------------------------------
|
|
170
173
|
|
|
171
|
-
def contain(pid, reader, started, timeout)
|
|
174
|
+
def contain(pid, reader, started, timeout, on_tick)
|
|
172
175
|
tree = ProcessTree.new(pid)
|
|
173
176
|
self.class.live[pid] = tree
|
|
174
177
|
own_group(pid)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
178
|
+
begin
|
|
179
|
+
payload, stop, detail = watch(tree, reader, started + timeout, on_tick)
|
|
180
|
+
ensure
|
|
181
|
+
# Containment holds on every path out of watch: a raising on_tick
|
|
182
|
+
# (progress IO failed) or any other error must not skip the
|
|
183
|
+
# kill/reap, or the child and its tree would run unsupervised
|
|
184
|
+
# until this process exits.
|
|
185
|
+
exit_status = stop == :timeout ? terminate(pid) : nil
|
|
186
|
+
leftover_pids = tree.kill_all - [pid]
|
|
187
|
+
exit_status ||= reap(pid)
|
|
188
|
+
self.class.live.delete(pid)
|
|
189
|
+
reap_adopted(leftover_pids)
|
|
190
|
+
reap_group(pid)
|
|
191
|
+
end
|
|
182
192
|
Result.new(payload: payload, exit_status: exit_status, stopped_by: stop, detail: detail,
|
|
183
193
|
runtime: monotonic - started, leftover_pids: leftover_pids)
|
|
184
194
|
end
|
|
185
195
|
|
|
186
196
|
# Reads until EOF, polling the tree's size and memory. Returns
|
|
187
197
|
# [payload, stop, detail].
|
|
188
|
-
def watch(tree, reader, deadline)
|
|
198
|
+
def watch(tree, reader, deadline, on_tick)
|
|
189
199
|
payload = +""
|
|
190
200
|
next_poll = monotonic + POLL_INTERVAL
|
|
191
201
|
tree.live_members
|
|
@@ -193,6 +203,8 @@ module Saboteur
|
|
|
193
203
|
now = monotonic
|
|
194
204
|
return [payload, :timeout, "exceeded deadline"] if now >= deadline
|
|
195
205
|
|
|
206
|
+
on_tick&.call
|
|
207
|
+
|
|
196
208
|
if now >= next_poll
|
|
197
209
|
stop = over_limit(tree)
|
|
198
210
|
return [payload, *stop] if stop
|