saboteur 0.2.0 → 0.3.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 +40 -0
- data/README.md +322 -54
- data/lib/saboteur/baseline_health.rb +116 -0
- data/lib/saboteur/cli.rb +169 -22
- data/lib/saboteur/config.rb +39 -5
- data/lib/saboteur/doctor.rb +72 -0
- data/lib/saboteur/engine/baseline_guard.rb +99 -0
- data/lib/saboteur/engine/candidate_selection.rb +35 -0
- data/lib/saboteur/engine/coverage_store.rb +9 -2
- data/lib/saboteur/engine/distribution.rb +4 -2
- data/lib/saboteur/engine/run_fingerprint.rb +316 -0
- data/lib/saboteur/engine/verdict_check.rb +4 -2
- data/lib/saboteur/engine.rb +53 -14
- data/lib/saboteur/execution/fork_readiness.rb +46 -0
- data/lib/saboteur/help.rb +194 -0
- data/lib/saboteur/integration/base.rb +8 -0
- data/lib/saboteur/integration/rspec.rb +23 -2
- data/lib/saboteur/mutation_source/builtin.rb +6 -1
- data/lib/saboteur/mutation_source/filter_replacement.rb +60 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +74 -0
- data/lib/saboteur/replay.rb +33 -0
- data/lib/saboteur/reporter/findings.rb +123 -0
- data/lib/saboteur/reporter/html.rb +165 -0
- data/lib/saboteur/reporter/replay.rb +23 -0
- data/lib/saboteur/reporter/stryker.rb +11 -1
- data/lib/saboteur/reporter/terminal.rb +11 -0
- data/lib/saboteur/result.rb +16 -5
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur.rb +3 -0
- metadata +15 -2
data/lib/saboteur/cli.rb
CHANGED
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
|
|
4
4
|
require "optparse"
|
|
5
5
|
require_relative "../saboteur"
|
|
6
|
+
require_relative "doctor"
|
|
7
|
+
require_relative "help"
|
|
8
|
+
require_relative "reporter/replay"
|
|
6
9
|
|
|
7
10
|
module Saboteur
|
|
8
11
|
class CLI
|
|
9
|
-
USAGE = "Usage: saboteur run [
|
|
12
|
+
USAGE = "Usage: saboteur run [SOURCE...] [--test TEST...] [--threshold PERCENT] [--since REF] " \
|
|
13
|
+
"[--subject 'Klass#meth'] [--timeout-factor N] [--jobs N] [--seed N] [--id DIGEST]
|
|
14
|
+
" \
|
|
15
|
+
"Run 'saboteur help' for every command, option, config key and exit code."
|
|
10
16
|
|
|
11
17
|
EXIT_OK = 0
|
|
12
18
|
EXIT_SURVIVORS = 1
|
|
@@ -26,8 +32,11 @@ module Saboteur
|
|
|
26
32
|
|
|
27
33
|
def call
|
|
28
34
|
case @argv.shift
|
|
29
|
-
when "run" then run
|
|
35
|
+
when "run" then help_requested? ? show_help("run") : run
|
|
36
|
+
when "doctor" then help_requested? ? show_help("doctor") : doctor
|
|
30
37
|
when "version", "--version" then @out.puts(VERSION) || EXIT_OK
|
|
38
|
+
when "help" then show_help(@argv.shift)
|
|
39
|
+
when "--help", "-h" then show_help(nil)
|
|
31
40
|
else usage_error
|
|
32
41
|
end
|
|
33
42
|
rescue OptionParser::ParseError => e
|
|
@@ -38,8 +47,44 @@ module Saboteur
|
|
|
38
47
|
@config ||= Config.load(root: Dir.pwd, path: options[:config], overrides: overrides)
|
|
39
48
|
end
|
|
40
49
|
|
|
50
|
+
def parser
|
|
51
|
+
OptionParser.new do |opts|
|
|
52
|
+
opts.banner = USAGE
|
|
53
|
+
define_selection_options(opts)
|
|
54
|
+
define_explicit_file_options(opts)
|
|
55
|
+
define_execution_options(opts)
|
|
56
|
+
define_output_options(opts)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Each command's own option lines, as printed under OPTIONS in `saboteur help`.
|
|
61
|
+
def option_lines
|
|
62
|
+
{ "run" => parser.summarize, "doctor" => doctor_parser.summarize }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def doctor_parser
|
|
66
|
+
OptionParser.new do |opts|
|
|
67
|
+
opts.banner = "Usage: saboteur doctor [--config PATH] [--seed N]"
|
|
68
|
+
opts.on("--config PATH", "Config file (default .saboteur.yml)") { |value| set(:config, value) }
|
|
69
|
+
opts.on("--seed N", Integer, "Seed for the two baseline runs (default 1)") { |value| set(:seed, value) }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
41
73
|
private
|
|
42
74
|
|
|
75
|
+
def help_requested?
|
|
76
|
+
@argv.intersect?(%w[--help -h])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def show_help(command_name)
|
|
80
|
+
help = Help.new(option_lines: option_lines)
|
|
81
|
+
text = command_name ? help.command(command_name) : help.overview
|
|
82
|
+
return @out.puts(text) || EXIT_OK if text
|
|
83
|
+
|
|
84
|
+
@err.puts("unknown command: #{command_name}. Commands: #{help.command_names.join(', ')}")
|
|
85
|
+
EXIT_USAGE
|
|
86
|
+
end
|
|
87
|
+
|
|
43
88
|
def usage_error(message = nil)
|
|
44
89
|
@err.puts(message) if message
|
|
45
90
|
@err.puts(USAGE)
|
|
@@ -48,12 +93,16 @@ module Saboteur
|
|
|
48
93
|
|
|
49
94
|
def run
|
|
50
95
|
parser.parse!(@argv)
|
|
51
|
-
|
|
96
|
+
use_explicit_sources
|
|
52
97
|
|
|
53
|
-
|
|
98
|
+
arguments = { err: @err }
|
|
99
|
+
arguments[:digest] = options[:id] if options[:id]
|
|
100
|
+
report = Engine.new(config, **arguments).call
|
|
54
101
|
@err.puts
|
|
55
102
|
Reporter::Terminal.new(@out).call(report)
|
|
103
|
+
Reporter::Replay.new(@out).call(report) if options[:id]
|
|
56
104
|
write_json(report)
|
|
105
|
+
write_html(report)
|
|
57
106
|
exit_code(report)
|
|
58
107
|
rescue ConfigError => e
|
|
59
108
|
@err.puts("saboteur: #{e.message}")
|
|
@@ -63,6 +112,20 @@ module Saboteur
|
|
|
63
112
|
EXIT_TOOL
|
|
64
113
|
end
|
|
65
114
|
|
|
115
|
+
def doctor
|
|
116
|
+
doctor_parser.parse!(@argv)
|
|
117
|
+
raise UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
|
|
118
|
+
|
|
119
|
+
Doctor.new(config, out: @out).call
|
|
120
|
+
EXIT_OK
|
|
121
|
+
rescue ConfigError => e
|
|
122
|
+
@err.puts("saboteur: #{e.message}")
|
|
123
|
+
EXIT_USAGE
|
|
124
|
+
rescue Error => e
|
|
125
|
+
@err.puts("saboteur: #{e.message}")
|
|
126
|
+
EXIT_TOOL
|
|
127
|
+
end
|
|
128
|
+
|
|
66
129
|
def write_json(report)
|
|
67
130
|
path = config.path("json_report")
|
|
68
131
|
return unless path
|
|
@@ -71,33 +134,117 @@ module Saboteur
|
|
|
71
134
|
@out.puts "\nJSON report: #{path}"
|
|
72
135
|
end
|
|
73
136
|
|
|
137
|
+
def write_html(report)
|
|
138
|
+
path = config.path("html_report")
|
|
139
|
+
return unless path
|
|
140
|
+
|
|
141
|
+
Reporter::Html.new(path).call(report)
|
|
142
|
+
@out.puts "HTML report: #{path}"
|
|
143
|
+
end
|
|
144
|
+
|
|
74
145
|
def exit_code(report)
|
|
75
146
|
executed = report.executed
|
|
76
147
|
return EXIT_TOOL if executed.any? && executed.all? { |result| result.status == :error }
|
|
77
148
|
|
|
78
|
-
|
|
149
|
+
return EXIT_TOOL if incomplete_under_threshold?(report)
|
|
150
|
+
|
|
151
|
+
failed_quality_gate?(report) ? EXIT_SURVIVORS : EXIT_OK
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# With --threshold the score decides; without it any unignored survivor does.
|
|
155
|
+
def failed_quality_gate?(report)
|
|
156
|
+
threshold = config["threshold"]
|
|
157
|
+
return report.unignored_survivors.any? if threshold.nil?
|
|
158
|
+
|
|
159
|
+
score = report.score
|
|
160
|
+
return missing_score_reported? if score.nil?
|
|
161
|
+
|
|
162
|
+
score * 100 < threshold
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# A gate that cannot measure a score must not pass: CI would go green
|
|
166
|
+
# on a run that tested nothing.
|
|
167
|
+
def missing_score_reported?
|
|
168
|
+
@err.puts("saboteur: no mutant was tested, so there is no score to compare with --threshold")
|
|
169
|
+
true
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Errors and resource limits are outside the score. With a threshold they
|
|
173
|
+
# make the run incomplete, so the score alone cannot pass it.
|
|
174
|
+
def incomplete_under_threshold?(report)
|
|
175
|
+
return false if config["threshold"].nil?
|
|
176
|
+
|
|
177
|
+
count = report.counts.values_at(:error, :resource_limit).sum
|
|
178
|
+
@err.puts("saboteur: #{count} mutant(s) errored or hit a resource limit, so the score is incomplete") if
|
|
179
|
+
count.positive?
|
|
180
|
+
count.positive?
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Positional arguments name the files (or directories) to mutate. They
|
|
184
|
+
# replace the configured include and exclude patterns. Each must exist,
|
|
185
|
+
# so a typo is an error and not a run over zero subjects.
|
|
186
|
+
def use_explicit_sources
|
|
187
|
+
raise UsageError, "--test needs at least one source file" if options[:tests] && @argv.empty?
|
|
188
|
+
return if @argv.empty?
|
|
189
|
+
|
|
190
|
+
options[:include] = @argv.map { |source| source_pattern(source) }
|
|
191
|
+
options[:exclude] = []
|
|
192
|
+
options[:tests]&.each { |test| require_existing_test(test) }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def source_pattern(source)
|
|
196
|
+
return "#{glob_escape(source.chomp('/'))}/**/*.rb" if File.directory?(source)
|
|
197
|
+
raise UsageError, "source is not a Ruby file: #{source}" if File.file?(source) && !source.end_with?(".rb")
|
|
198
|
+
raise UsageError, "source file not found: #{source}" unless File.file?(source)
|
|
199
|
+
|
|
200
|
+
glob_escape(source)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# include is a list of glob patterns; a named file must match only itself.
|
|
204
|
+
def glob_escape(path)
|
|
205
|
+
path.gsub(/[\\*?\[\]{}]/) { |character| "\\#{character}" }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# A test may carry an example selector: spec/a_spec.rb:12 or spec/a_spec.rb[1:2].
|
|
209
|
+
def require_existing_test(test)
|
|
210
|
+
path = test.sub(/(:\d+|\[[\d:,]+\])+\z/, "")
|
|
211
|
+
raise UsageError, "test file not found: #{test}" unless File.exist?(path)
|
|
79
212
|
end
|
|
80
213
|
|
|
81
214
|
def overrides
|
|
82
|
-
options.except(:config)
|
|
215
|
+
options.except(:config, :id)
|
|
83
216
|
end
|
|
84
217
|
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
218
|
+
def define_selection_options(opts)
|
|
219
|
+
opts.on("--id DIGEST", "Replay exactly one current mutant by its full digest") { |v| set(:id, v) }
|
|
220
|
+
opts.on("--since REF", "Only mutate subjects changed since git REF") { |v| options[:since] = v }
|
|
221
|
+
opts.on("--subject EXPR", "Klass#meth, Klass.meth or a namespace; repeatable") { |v| add_subject(v) }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def define_execution_options(opts)
|
|
225
|
+
opts.on("--timeout-factor N", Float, "Multiple of baseline runtime (default 3)") { |v| set(:timeout_factor, v) }
|
|
226
|
+
opts.on("--jobs N", Integer, "Concurrent workers (default 1)") { |v| set(:jobs, v) }
|
|
227
|
+
opts.on("--seed N", Integer, "Seed for Ruby's random numbers in every test run (default 1)") do |v|
|
|
228
|
+
set(:seed, v)
|
|
229
|
+
end
|
|
230
|
+
opts.on("--config PATH", "Config file (default .saboteur.yml)") { |v| set(:config, v) }
|
|
231
|
+
opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
|
|
232
|
+
opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
|
|
233
|
+
opts.on("--no-cache", "Rebuild the coverage map") { set(:cache, false) }
|
|
234
|
+
opts.on("--incremental", "Reuse earlier verdicts whose inputs did not change") { set(:incremental, true) }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def define_output_options(opts)
|
|
238
|
+
opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
|
|
239
|
+
opts.on("--html PATH", "Self-contained HTML report path") { |v| set(:html_report, v) }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def define_explicit_file_options(opts)
|
|
243
|
+
opts.on("--test PATH", "Spec file or directory to run; repeatable. Replaces the configured spec paths") do |v|
|
|
244
|
+
(options[:tests] ||= []) << v
|
|
245
|
+
end
|
|
246
|
+
opts.on("--threshold PERCENT", Float, "Exit 1 when the score is below PERCENT (0-100)") do |v|
|
|
247
|
+
set(:threshold, v)
|
|
101
248
|
end
|
|
102
249
|
end
|
|
103
250
|
|
data/lib/saboteur/config.rb
CHANGED
|
@@ -21,6 +21,7 @@ module Saboteur
|
|
|
21
21
|
"coverage_timeout" => 3600.0,
|
|
22
22
|
"max_memory_mb" => 2048,
|
|
23
23
|
"max_processes" => 64,
|
|
24
|
+
"max_mutants_per_method" => 100,
|
|
24
25
|
"jobs" => 1,
|
|
25
26
|
"ignore_file" => ".saboteur-ignore.yml",
|
|
26
27
|
"sources" => ["builtin"],
|
|
@@ -29,17 +30,22 @@ module Saboteur
|
|
|
29
30
|
"reeval" => "nesting",
|
|
30
31
|
"cache_dir" => ".saboteur",
|
|
31
32
|
"json_report" => ".saboteur/report.json",
|
|
33
|
+
"html_report" => ".saboteur/report.html",
|
|
32
34
|
"rspec_args" => [],
|
|
33
35
|
"subjects" => [],
|
|
34
36
|
"since" => nil,
|
|
35
37
|
"cache" => true,
|
|
36
38
|
"incremental" => false,
|
|
37
|
-
"seed" => 1
|
|
39
|
+
"seed" => 1,
|
|
40
|
+
"tests" => [],
|
|
41
|
+
"threshold" => nil
|
|
38
42
|
}.freeze
|
|
39
43
|
|
|
40
|
-
INTEGER_KEYS = %w[jobs max_memory_mb max_processes seed].freeze
|
|
44
|
+
INTEGER_KEYS = %w[jobs max_memory_mb max_processes max_mutants_per_method seed].freeze
|
|
41
45
|
FLOAT_KEYS = %w[timeout_factor timeout_min baseline_timeout coverage_timeout].freeze
|
|
42
|
-
|
|
46
|
+
THRESHOLD_RANGE = (0.0..100.0)
|
|
47
|
+
|
|
48
|
+
LIST_KEYS = %w[require include exclude arid sources hooks rspec_args subjects tests].freeze
|
|
43
49
|
|
|
44
50
|
attr_reader :root
|
|
45
51
|
|
|
@@ -70,6 +76,7 @@ module Saboteur
|
|
|
70
76
|
@values = DEFAULTS.merge(values)
|
|
71
77
|
coerce!
|
|
72
78
|
validate!
|
|
79
|
+
validate_report_paths!
|
|
73
80
|
end
|
|
74
81
|
|
|
75
82
|
def [](key)
|
|
@@ -111,19 +118,46 @@ module Saboteur
|
|
|
111
118
|
INTEGER_KEYS.each { |key| @values[key] = Integer(@values[key]) }
|
|
112
119
|
FLOAT_KEYS.each { |key| @values[key] = Float(@values[key]) }
|
|
113
120
|
LIST_KEYS.each { |key| @values[key] = Array(@values[key]).map(&:to_s) }
|
|
121
|
+
@values["threshold"] = Float(@values["threshold"]) unless @values["threshold"].nil?
|
|
114
122
|
rescue ArgumentError, TypeError => e
|
|
115
123
|
raise ConfigError, "invalid config value: #{e.message}"
|
|
116
124
|
end
|
|
117
125
|
|
|
126
|
+
def validate_mutant_limit!
|
|
127
|
+
raise ConfigError, "max_mutants_per_method must be > 0" unless self["max_mutants_per_method"].positive?
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def validate_report_paths!
|
|
131
|
+
json = path("json_report")
|
|
132
|
+
return unless json && json == path("html_report")
|
|
133
|
+
|
|
134
|
+
raise ConfigError, "json_report and html_report both point to #{json}; give each report its own path"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def validate_threshold!
|
|
138
|
+
threshold = self["threshold"]
|
|
139
|
+
return if threshold.nil? || THRESHOLD_RANGE.cover?(threshold)
|
|
140
|
+
|
|
141
|
+
raise ConfigError, "threshold must be between 0 and 100"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def validate_reeval!
|
|
145
|
+
raise ConfigError, "reeval must be one of: #{Execution::Reevaluator::STRATEGIES.join(', ')}" unless
|
|
146
|
+
Execution::Reevaluator::STRATEGIES.include?(self["reeval"].to_s)
|
|
147
|
+
end
|
|
148
|
+
|
|
118
149
|
def validate!
|
|
119
150
|
raise ConfigError, "jobs must be >= 1" if self["jobs"] < 1
|
|
151
|
+
|
|
152
|
+
validate_mutant_limit!
|
|
153
|
+
validate_threshold!
|
|
120
154
|
raise ConfigError, "seed must be >= 0" if self["seed"].negative?
|
|
121
155
|
raise ConfigError, "max_memory_mb must be > 0" unless self["max_memory_mb"].positive?
|
|
122
156
|
raise ConfigError, "max_processes must be > 0" unless self["max_processes"].positive?
|
|
123
157
|
raise ConfigError, "timeout_factor must be > 0" unless self["timeout_factor"].positive?
|
|
124
158
|
raise ConfigError, "timeout_min must be > 0" unless self["timeout_min"].positive?
|
|
125
|
-
|
|
126
|
-
|
|
159
|
+
|
|
160
|
+
validate_reeval!
|
|
127
161
|
|
|
128
162
|
self["arid"].each { |pattern| Regexp.new(pattern) }
|
|
129
163
|
rescue RegexpError => e
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "coverage"
|
|
5
|
+
require_relative "baseline_health"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
# Reports the current environment and runs baseline checks, never mutations.
|
|
9
|
+
class Doctor
|
|
10
|
+
def initialize(config, out: $stdout)
|
|
11
|
+
@config = config
|
|
12
|
+
@out = out
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
Engine::Boot.refuse_recursion!
|
|
17
|
+
check_runtime
|
|
18
|
+
check_coverage
|
|
19
|
+
check_boot_files
|
|
20
|
+
hooks = Hooks.new
|
|
21
|
+
integration = boot_target(hooks)
|
|
22
|
+
@out.puts "Integration: #{@config['integration']} (loaded)"
|
|
23
|
+
@out.puts "Rails: #{@config['rails'] ? 'enabled' : 'disabled'}"
|
|
24
|
+
@out.puts "SimpleCov: #{defined?(::SimpleCov) ? 'present; start suppressed for this process' : 'not loaded'}"
|
|
25
|
+
check_coverage
|
|
26
|
+
tests = BaselineHealth.new(@config, integration: integration, hooks: hooks).call
|
|
27
|
+
@out.puts "Baseline: #{tests.size} tests passed twice in the same order with seed #{@config['seed']}"
|
|
28
|
+
@out.puts "Healthy in these two runs. This does not prove the suite is free of flaky tests."
|
|
29
|
+
true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def boot_target(hooks)
|
|
35
|
+
Engine::Boot.new(@config, hooks).load_target
|
|
36
|
+
rescue LoadError, SyntaxError, SystemExit, StandardError => e
|
|
37
|
+
raise Error, "target boot failed: #{e.class}: #{e.message}; check require files, hooks and rspec_args"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def check_runtime
|
|
41
|
+
@out.puts "Ruby: #{RUBY_DESCRIPTION}"
|
|
42
|
+
unless Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.3") && Process.respond_to?(:fork)
|
|
43
|
+
raise Error, "doctor needs Ruby 3.3+ with fork; use supported Ruby on Linux or macOS"
|
|
44
|
+
end
|
|
45
|
+
return if @config["integration"] == "rspec"
|
|
46
|
+
|
|
47
|
+
raise ConfigError,
|
|
48
|
+
"doctor supports the rspec integration; set integration: rspec"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def check_coverage
|
|
52
|
+
state = Coverage.state
|
|
53
|
+
@out.puts "Coverage: #{state}"
|
|
54
|
+
return if Coverage.supported?(:lines) && state == :suspended
|
|
55
|
+
|
|
56
|
+
raise Error, "Coverage must be suspended with line support before boot; use the saboteur executable, " \
|
|
57
|
+
"disable SimpleCov and remove other Coverage.start calls"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def check_boot_files
|
|
61
|
+
%w[require hooks].each do |key|
|
|
62
|
+
@config[key].each do |file|
|
|
63
|
+
path = File.expand_path(file, @config.root)
|
|
64
|
+
raise ConfigError, "#{key} file missing: #{file}; fix .saboteur.yml" unless File.file?(path)
|
|
65
|
+
|
|
66
|
+
@out.puts "#{key}: #{file} (found)"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
@config.unmatched_patterns.each { |pattern| @out.puts "Warning: #{pattern} matches no file" }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
class Engine
|
|
9
|
+
# Two fresh children check the unchanged suite before any cached verdict
|
|
10
|
+
# or mutation source is used. This catches observed instability, not all
|
|
11
|
+
# possible flaky behaviour.
|
|
12
|
+
class BaselineGuard
|
|
13
|
+
def initialize(config:, integration:, hooks:, supervisor:, diagnostics:)
|
|
14
|
+
@config = config
|
|
15
|
+
@integration = integration
|
|
16
|
+
@hooks = hooks
|
|
17
|
+
@supervisor = supervisor
|
|
18
|
+
@diagnostics = diagnostics
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
expected = @integration.all_tests.map(&:id)
|
|
23
|
+
raise Error, "baseline has no tests. Check rspec_args and the suite filters before running saboteur again." if
|
|
24
|
+
expected.empty?
|
|
25
|
+
|
|
26
|
+
@diagnostics.inform("checking the unchanged suite twice (seed #{@config['seed']})")
|
|
27
|
+
previous = nil
|
|
28
|
+
2.times do |index|
|
|
29
|
+
number = index + 1
|
|
30
|
+
data = check(number)
|
|
31
|
+
unless data["passed"] && data["failed"].empty?
|
|
32
|
+
refuse(number,
|
|
33
|
+
"the suite failed: #{Array(data['failed']).join(', ')}")
|
|
34
|
+
end
|
|
35
|
+
executed = data["executed"]
|
|
36
|
+
refuse(number, "executed tests do not match the loaded suite") unless
|
|
37
|
+
executed.sort == expected.sort
|
|
38
|
+
refuse(number, "test order changed between baseline checks") if previous && executed != previous
|
|
39
|
+
previous = executed
|
|
40
|
+
end
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def check(number)
|
|
47
|
+
Execution::ForkReadiness.prepare_heap
|
|
48
|
+
result = @supervisor.run(timeout: @config["coverage_timeout"]) { |writer| record(writer, number) }
|
|
49
|
+
refuse(number, "the suite stopped: #{result.stopped_by} (#{result.detail})") if result.stopped_by
|
|
50
|
+
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
|
+
refuse(number, "the suite left child processes behind") if result.leftover_pids.any?
|
|
54
|
+
parse_result(number, result.payload)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def parse_result(number, payload)
|
|
58
|
+
data = JSON.parse(payload)
|
|
59
|
+
refuse(number, "the child reported an invalid result") unless data.is_a?(Hash)
|
|
60
|
+
refuse(number, data["error"]) if data["error"]
|
|
61
|
+
refuse(number, "the child reported invalid test results") unless valid_result?(data)
|
|
62
|
+
data
|
|
63
|
+
rescue JSON::ParserError
|
|
64
|
+
refuse(number, "the child reported invalid JSON")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def valid_result?(data)
|
|
68
|
+
[true, false].include?(data["passed"]) && data["failed"].is_a?(Array) &&
|
|
69
|
+
data["executed"].is_a?(Array) && data["executed"].all?(String)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def record(writer, number)
|
|
73
|
+
path = log_path(number)
|
|
74
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
75
|
+
$stdout.reopen(path, "w")
|
|
76
|
+
$stdout.sync = true
|
|
77
|
+
$stderr.reopen($stdout)
|
|
78
|
+
@hooks.run(:after_fork)
|
|
79
|
+
Kernel.srand(@config["seed"])
|
|
80
|
+
result = @integration.run_baseline(seed: @config["seed"])
|
|
81
|
+
writer.write(JSON.generate(passed: result.passed, failed: result.failed_ids, executed: result.executed_ids))
|
|
82
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
83
|
+
writer.write(JSON.generate(error: "#{e.class}: #{e.message}"))
|
|
84
|
+
ensure
|
|
85
|
+
writer.close
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def log_path(number)
|
|
89
|
+
File.join(@config.path("cache_dir"), "baseline-#{number}.log")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def refuse(number, reason)
|
|
93
|
+
raise Error, "baseline check #{number} failed at seed #{@config['seed']}: #{reason}. " \
|
|
94
|
+
"See the log at #{log_path(number)}. Fix the suite or its setup, then run saboteur again " \
|
|
95
|
+
"with the same seed. No mutants were started."
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# Selects a bounded list for each covered subject before workers fork.
|
|
7
|
+
# Subject skips are not mutants and do not enter the mutation score.
|
|
8
|
+
class CandidateSelection
|
|
9
|
+
attr_reader :skipped_subjects
|
|
10
|
+
|
|
11
|
+
def initialize(candidates:, map:, limit:, diagnostics:)
|
|
12
|
+
@candidates = candidates
|
|
13
|
+
@map = map
|
|
14
|
+
@limit = limit
|
|
15
|
+
@diagnostics = diagnostics
|
|
16
|
+
@skipped_subjects = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def for(subject, scope: nil)
|
|
20
|
+
if @map.tests_for(subject).empty?
|
|
21
|
+
@skipped_subjects[subject] = "no coverage"
|
|
22
|
+
return []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
list = @candidates.for(subject)
|
|
26
|
+
list = scope.restrict(subject, list) if scope
|
|
27
|
+
omitted = list.size - @limit
|
|
28
|
+
if omitted.positive?
|
|
29
|
+
@diagnostics.warn("#{subject.id}: omitted #{omitted} mutant(s) above max_mutants_per_method (#{@limit})")
|
|
30
|
+
end
|
|
31
|
+
list.first(@limit)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -11,7 +11,7 @@ module Saboteur
|
|
|
11
11
|
# unchanged, otherwise built by running the whole suite once in a
|
|
12
12
|
# supervised child and saved.
|
|
13
13
|
class CoverageStore
|
|
14
|
-
CACHE_INPUTS = %w[integration require rspec_args rails].freeze
|
|
14
|
+
CACHE_INPUTS = %w[integration require rspec_args rails tests].freeze
|
|
15
15
|
LOCKFILES = %w[Gemfile.lock gems.locked].freeze
|
|
16
16
|
LOG = "coverage-build.log"
|
|
17
17
|
|
|
@@ -53,6 +53,9 @@ module Saboteur
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
@diagnostics.inform("building coverage map (runs the whole suite once)")
|
|
56
|
+
# The suite child forked next inherits this heap; pack it first, to
|
|
57
|
+
# reduce later copying of shared pages.
|
|
58
|
+
Execution::ForkReadiness.prepare_heap
|
|
56
59
|
result = @supervisor.run(timeout: @config["coverage_timeout"]) { |writer| record(writer, files) }
|
|
57
60
|
raise Error, "coverage map build stopped: #{result.detail}" if result.stopped_by
|
|
58
61
|
raise Error, "coverage map build failed: #{died(result.exit_status)}" if result.payload.empty?
|
|
@@ -86,7 +89,11 @@ module Saboteur
|
|
|
86
89
|
recorder = Selection::Recorder.new(files)
|
|
87
90
|
recorder.start
|
|
88
91
|
Kernel.srand(@config["seed"])
|
|
89
|
-
@integration.run_all(recorder)
|
|
92
|
+
passed = @integration.run_all(recorder)
|
|
93
|
+
if passed == false
|
|
94
|
+
raise Error, "the unchanged suite failed while building coverage. Fix the suite, then run again " \
|
|
95
|
+
"with seed #{@config['seed']}."
|
|
96
|
+
end
|
|
90
97
|
build_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
91
98
|
writer.write(recorder.result(build_seconds: build_seconds).to_json)
|
|
92
99
|
writer.close
|
|
@@ -42,7 +42,8 @@ module Saboteur
|
|
|
42
42
|
"position" => list.index { |mutation, _| mutation.equal?(result.mutation) },
|
|
43
43
|
"status" => result.status.to_s, "reason" => result.reason, "runtime" => result.runtime,
|
|
44
44
|
"killed_by" => result.killed_by, "covered_by" => result.covered_by,
|
|
45
|
-
"kill_cause" => result.kill_cause, "reused" => result.reused
|
|
45
|
+
"kill_cause" => result.kill_cause, "reused" => result.reused?,
|
|
46
|
+
"rejected_kill" => result.rejected_kill?
|
|
46
47
|
}
|
|
47
48
|
end
|
|
48
49
|
|
|
@@ -55,7 +56,8 @@ module Saboteur
|
|
|
55
56
|
result = MutantResult.new(subject: subject, mutation: mutation, status: data["status"].to_sym,
|
|
56
57
|
mutated_source: text, reason: data["reason"], runtime: data["runtime"],
|
|
57
58
|
killed_by: data["killed_by"], covered_by: data["covered_by"],
|
|
58
|
-
kill_cause: data["kill_cause"], reused: data["reused"]
|
|
59
|
+
kill_cause: data["kill_cause"], reused: data["reused"],
|
|
60
|
+
rejected_kill: data.fetch("rejected_kill", false))
|
|
59
61
|
@cache&.record(result) unless result.reused?
|
|
60
62
|
result
|
|
61
63
|
end
|