saboteur 0.2.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 +7 -0
- data/CHANGELOG.md +47 -0
- data/LICENSE +21 -0
- data/README.md +145 -0
- data/exe/saboteur +12 -0
- data/lib/saboteur/cli.rb +112 -0
- data/lib/saboteur/config.rb +133 -0
- data/lib/saboteur/engine/boot.rb +83 -0
- data/lib/saboteur/engine/candidates.rb +63 -0
- data/lib/saboteur/engine/coverage_store.rb +99 -0
- data/lib/saboteur/engine/diagnostics.rb +37 -0
- data/lib/saboteur/engine/diff_scope.rb +32 -0
- data/lib/saboteur/engine/distribution.rb +74 -0
- data/lib/saboteur/engine/result_cache.rb +93 -0
- data/lib/saboteur/engine/scheduler.rb +215 -0
- data/lib/saboteur/engine/subject_selection.rb +63 -0
- data/lib/saboteur/engine/suite_files.rb +82 -0
- data/lib/saboteur/engine/verdict_check.rb +78 -0
- data/lib/saboteur/engine.rb +133 -0
- data/lib/saboteur/execution/process_table.rb +177 -0
- data/lib/saboteur/execution/process_tree.rb +99 -0
- data/lib/saboteur/execution/protocol.rb +41 -0
- data/lib/saboteur/execution/reevaluator.rb +144 -0
- data/lib/saboteur/execution/runner.rb +191 -0
- data/lib/saboteur/execution/supervisor.rb +337 -0
- data/lib/saboteur/execution/worker_pool.rb +249 -0
- data/lib/saboteur/frameworks/rails.rb +166 -0
- data/lib/saboteur/hooks.rb +51 -0
- data/lib/saboteur/ignore_list.rb +62 -0
- data/lib/saboteur/integration/base.rb +72 -0
- data/lib/saboteur/integration/rspec.rb +143 -0
- data/lib/saboteur/mutation_source/base.rb +26 -0
- data/lib/saboteur/mutation_source/builtin.rb +232 -0
- data/lib/saboteur/mutation_source/edit.rb +18 -0
- data/lib/saboteur/mutation_source/mutation.rb +28 -0
- data/lib/saboteur/reporter/diff.rb +97 -0
- data/lib/saboteur/reporter/stryker.rb +116 -0
- data/lib/saboteur/reporter/terminal.rb +136 -0
- data/lib/saboteur/result.rb +134 -0
- data/lib/saboteur/selection/coverage_map.rb +113 -0
- data/lib/saboteur/selection/git_diff.rb +64 -0
- data/lib/saboteur/selection/recorder.rb +72 -0
- data/lib/saboteur/subject.rb +166 -0
- data/lib/saboteur/subject_finder.rb +98 -0
- data/lib/saboteur/version.rb +6 -0
- data/lib/saboteur.rb +51 -0
- metadata +130 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "process_tree"
|
|
6
|
+
require_relative "supervisor"
|
|
7
|
+
|
|
8
|
+
module Saboteur
|
|
9
|
+
module Execution
|
|
10
|
+
# Runs a block over items on several worker processes forked from the
|
|
11
|
+
# booted parent, and returns the block's results in item order.
|
|
12
|
+
#
|
|
13
|
+
# Workers are processes, not threads: forking a mutant child from a
|
|
14
|
+
# threaded parent is unsafe, and processes keep each worker's state
|
|
15
|
+
# (and database connection) apart. Each worker takes one item at a time
|
|
16
|
+
# from the parent, so a slow item does not hold up the rest; biggest
|
|
17
|
+
# items go first when weights are given. Results travel back as JSON,
|
|
18
|
+
# so the block must return plain data.
|
|
19
|
+
#
|
|
20
|
+
# Workers are numbered from 0; TEST_ENV_NUMBER follows the parallel test
|
|
21
|
+
# convention Rails apps use for one database per worker ("", "2", "3").
|
|
22
|
+
# A worker that dies has its item reported as an error, with the reason
|
|
23
|
+
# (what it raised, or the signal), and is replaced. When workers keep
|
|
24
|
+
# dying before finishing a single item, the pool stops with that reason.
|
|
25
|
+
class WorkerPool
|
|
26
|
+
# done: items finished; status: the Process::Status once reaped;
|
|
27
|
+
# last_words: what the worker reported before dying, if anything.
|
|
28
|
+
Worker = Struct.new(:number, :pid, :commands, :results, :current, :done, :status, :last_words)
|
|
29
|
+
START_FAILURES = 3
|
|
30
|
+
|
|
31
|
+
# on_start: called in each worker with its number before its first
|
|
32
|
+
# item (per-worker database connections, for example).
|
|
33
|
+
def initialize(jobs:, on_start: nil)
|
|
34
|
+
@jobs = jobs
|
|
35
|
+
@on_start = on_start
|
|
36
|
+
@start_failures = 0
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def map(items, weights: nil, &work)
|
|
40
|
+
order = items.each_index.sort_by { |index| [-(weights ? weights[index] : 0), index] }
|
|
41
|
+
return inline(items, order, &work) if @jobs <= 1
|
|
42
|
+
|
|
43
|
+
distribute(items, order, &work)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def inline(items, order, &work)
|
|
49
|
+
order.each_with_object(Array.new(items.size)) { |index, results| results[index] = work.call(items[index]) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def distribute(items, order, &work)
|
|
53
|
+
results = Array.new(items.size)
|
|
54
|
+
queue = order.dup
|
|
55
|
+
workers = []
|
|
56
|
+
[@jobs, items.size].min.times { |number| workers << start(number, items, workers, &work) }
|
|
57
|
+
workers.each { |worker| assign(worker, queue) }
|
|
58
|
+
collect(workers, queue, results, items, &work) until workers.empty?
|
|
59
|
+
results
|
|
60
|
+
ensure
|
|
61
|
+
workers&.each { |worker| stop(worker) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Waits up to a second for results, then checks for workers that died
|
|
65
|
+
# without closing their pipe (a stray grandchild may still hold it).
|
|
66
|
+
def collect(workers, queue, results, items, &work)
|
|
67
|
+
ready = IO.select(workers.map(&:results), nil, nil, 1)&.first || []
|
|
68
|
+
ready.each do |io|
|
|
69
|
+
worker = workers.find { |candidate| candidate.results == io }
|
|
70
|
+
receive(worker, workers, queue, results, items, &work) if worker
|
|
71
|
+
end
|
|
72
|
+
workers.reject { |worker| alive?(worker) }.each do |worker|
|
|
73
|
+
replace(worker, workers, queue, results, items, &work)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Reads one line from a worker whose pipe is ready, then gives it its
|
|
78
|
+
# next item, retires it when none is left, or replaces it if it died.
|
|
79
|
+
def receive(worker, workers, queue, results, items, &)
|
|
80
|
+
line = worker.results.gets
|
|
81
|
+
take(worker, line, results) if line
|
|
82
|
+
return replace(worker, workers, queue, results, items, &) if line.nil? || worker.last_words
|
|
83
|
+
|
|
84
|
+
assign(worker, queue)
|
|
85
|
+
retire(worker, workers) unless worker.current
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse(line)
|
|
89
|
+
JSON.parse(line)
|
|
90
|
+
rescue JSON::ParserError => e
|
|
91
|
+
{ "error" => "unreadable result from worker: #{e.message}" }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def alive?(worker)
|
|
95
|
+
!reaped?(worker)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Stores a line from the worker: its item's result, or why it crashed.
|
|
99
|
+
def take(worker, line, results)
|
|
100
|
+
data = parse(line)
|
|
101
|
+
return worker.last_words = data["fatal"] if data.key?("fatal")
|
|
102
|
+
return unless worker.current
|
|
103
|
+
|
|
104
|
+
results[worker.current] = data
|
|
105
|
+
worker.done += 1
|
|
106
|
+
worker.current = nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Reads what a dead worker left in its pipe: a last result, or why it
|
|
110
|
+
# crashed.
|
|
111
|
+
def drain(worker, results)
|
|
112
|
+
pending = worker.results.read_nonblock(1 << 20, exception: false)
|
|
113
|
+
return unless pending.is_a?(String)
|
|
114
|
+
|
|
115
|
+
pending.each_line { |line| take(worker, line, results) if line.end_with?("\n") }
|
|
116
|
+
rescue IOError
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def replace(worker, workers, queue, results, items, &)
|
|
121
|
+
drain(worker, results)
|
|
122
|
+
retire(worker, workers)
|
|
123
|
+
reason = worker.last_words || "worker #{worker.number} died#{how(worker.status)}"
|
|
124
|
+
results[worker.current] = { "error" => "#{reason}, running this item" } if worker.current
|
|
125
|
+
give_up(reason) if worker.done.zero?
|
|
126
|
+
return if queue.empty?
|
|
127
|
+
|
|
128
|
+
fresh = start(worker.number, items, workers, &)
|
|
129
|
+
workers << fresh
|
|
130
|
+
assign(fresh, queue)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Sends the worker the next item, if any; worker.current is nil after
|
|
134
|
+
# when there was none left.
|
|
135
|
+
def assign(worker, queue)
|
|
136
|
+
worker.current = queue.shift
|
|
137
|
+
return unless worker.current
|
|
138
|
+
|
|
139
|
+
worker.commands.puts(worker.current)
|
|
140
|
+
worker.commands.flush
|
|
141
|
+
rescue Errno::EPIPE, IOError
|
|
142
|
+
queue.unshift(worker.current) # the worker died before taking it
|
|
143
|
+
worker.current = nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def how(status)
|
|
147
|
+
return "" unless status
|
|
148
|
+
return " (signal #{Signal.signame(status.termsig)})" if status.signaled?
|
|
149
|
+
|
|
150
|
+
" (exit #{status.exitstatus})"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# A worker that never finishes an item is a broken setup (on_start, a
|
|
154
|
+
# bug in the work itself), not bad luck: stop instead of failing every
|
|
155
|
+
# item one by one.
|
|
156
|
+
def give_up(reason)
|
|
157
|
+
@start_failures += 1
|
|
158
|
+
raise Error, "workers keep failing to start: #{reason}" if @start_failures >= START_FAILURES
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def retire(worker, workers)
|
|
162
|
+
workers.delete(worker)
|
|
163
|
+
stop(worker)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# siblings: workers already running. The new worker closes its copies
|
|
167
|
+
# of their pipe ends, or a sibling would never see its command pipe
|
|
168
|
+
# close (end of input) when the parent is done with it.
|
|
169
|
+
def start(number, items, siblings, &work)
|
|
170
|
+
commands_read, commands_write = IO.pipe
|
|
171
|
+
results_read, results_write = IO.pipe
|
|
172
|
+
pid = fork do
|
|
173
|
+
siblings.each { |sibling| [sibling.commands, sibling.results].each(&:close) }
|
|
174
|
+
commands_write.close
|
|
175
|
+
results_read.close
|
|
176
|
+
serve(number, items, commands_read, results_write, &work)
|
|
177
|
+
end
|
|
178
|
+
commands_read.close
|
|
179
|
+
results_write.close
|
|
180
|
+
own_group(pid)
|
|
181
|
+
Supervisor.live[pid] = ProcessTree.new(pid)
|
|
182
|
+
Worker.new(number, pid, commands_write, results_read, nil, 0)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Runs in the worker until the parent closes its command pipe. If it
|
|
186
|
+
# crashes, its last line says why.
|
|
187
|
+
def serve(number, items, commands, results, &)
|
|
188
|
+
Process.setpgid(0, 0)
|
|
189
|
+
Supervisor.reset_in_worker!
|
|
190
|
+
Supervisor.close_in_child.push(commands, results)
|
|
191
|
+
ENV["SABOTEUR_WORKER"] = number.to_s
|
|
192
|
+
ENV["TEST_ENV_NUMBER"] = number.zero? ? "" : (number + 1).to_s
|
|
193
|
+
@on_start&.call(number)
|
|
194
|
+
while (line = commands.gets)
|
|
195
|
+
results.puts(JSON.generate(run_one(items[Integer(line)], &)))
|
|
196
|
+
results.flush
|
|
197
|
+
end
|
|
198
|
+
exit!(0)
|
|
199
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
200
|
+
last_words(results, "worker #{number} crashed: #{e.class}: #{e.message} (#{e.backtrace&.first})")
|
|
201
|
+
exit!(1)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def last_words(results, message)
|
|
205
|
+
results.puts(JSON.generate("fatal" => message))
|
|
206
|
+
results.flush
|
|
207
|
+
rescue StandardError
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def run_one(item, &work)
|
|
212
|
+
work.call(item)
|
|
213
|
+
rescue StandardError => e
|
|
214
|
+
{ "error" => "#{e.class}: #{e.message}" }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Closing the command pipe ends the worker's loop; anything it left
|
|
218
|
+
# running is killed with its tree.
|
|
219
|
+
def stop(worker)
|
|
220
|
+
worker.commands.close unless worker.commands.closed?
|
|
221
|
+
tree = Supervisor.live.delete(worker.pid)
|
|
222
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
|
|
223
|
+
sleep 0.01 until reaped?(worker) || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
224
|
+
tree&.kill_all
|
|
225
|
+
worker.status ||= Process.wait2(worker.pid).last unless reaped?(worker)
|
|
226
|
+
worker.results.close unless worker.results.closed?
|
|
227
|
+
rescue Errno::ECHILD
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def own_group(pid)
|
|
232
|
+
Process.setpgid(pid, pid)
|
|
233
|
+
rescue Errno::EACCES, Errno::ESRCH, Errno::EPERM
|
|
234
|
+
nil
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Reaps the worker if it has exited, keeping its status.
|
|
238
|
+
def reaped?(worker)
|
|
239
|
+
return true if worker.status
|
|
240
|
+
|
|
241
|
+
pid, status = Process.waitpid2(worker.pid, Process::WNOHANG)
|
|
242
|
+
worker.status = status if pid
|
|
243
|
+
!pid.nil?
|
|
244
|
+
rescue Errno::ECHILD
|
|
245
|
+
true
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
module Frameworks
|
|
8
|
+
# Opt-in Rails support (`rails: true` in .saboteur.yml).
|
|
9
|
+
#
|
|
10
|
+
# Refuses to run unless RAILS_ENV is test and every configured database is
|
|
11
|
+
# local, eager loads the app so every subject's owner is defined before
|
|
12
|
+
# forking, and drops database connections in the parent so each child opens
|
|
13
|
+
# its own. Prints host names only, never credentials.
|
|
14
|
+
class Rails
|
|
15
|
+
LOCAL_HOSTS = ["localhost", "127.0.0.1", "::1", "[::1]"].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(config, hooks)
|
|
18
|
+
@config = config
|
|
19
|
+
@hooks = hooks
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def before_boot
|
|
23
|
+
env = ENV.fetch("RAILS_ENV", nil) || ENV.fetch("RACK_ENV", nil)
|
|
24
|
+
raise Error, "rails integration refuses to run unless RAILS_ENV=test (got #{env.inspect})" unless env == "test"
|
|
25
|
+
|
|
26
|
+
# libpq probes for Kerberos (GSS encryption) on every new connection;
|
|
27
|
+
# on macOS that goes through XPC, which crashes a forked process. The
|
|
28
|
+
# databases are local only (check_databases), so it buys nothing.
|
|
29
|
+
ENV["PGGSSENCMODE"] ||= "disable"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def after_boot
|
|
33
|
+
unless defined?(::Rails.env)
|
|
34
|
+
raise Error,
|
|
35
|
+
"rails: true but Rails did not load (add config/environment to require)"
|
|
36
|
+
end
|
|
37
|
+
raise Error, "rails integration refuses to run: Rails.env is #{::Rails.env}" unless ::Rails.env.test?
|
|
38
|
+
|
|
39
|
+
check_databases
|
|
40
|
+
::Rails.application.eager_load!
|
|
41
|
+
disconnect
|
|
42
|
+
@hooks.register(:before_fork) { |_mutation| disconnect }
|
|
43
|
+
@hooks.register(:after_fork) { reconnect }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The database a worker uses: the first keeps the test database, the
|
|
47
|
+
# others get copies named for saboteur, never a name parallel_tests or
|
|
48
|
+
# a developer might already use.
|
|
49
|
+
def self.worker_database(database, number)
|
|
50
|
+
return database if number.zero?
|
|
51
|
+
|
|
52
|
+
extension = File.extname(database)
|
|
53
|
+
"#{database.delete_suffix(extension)}_saboteur#{number + 1}#{extension}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Parent, before workers start: one copy of each test database per
|
|
57
|
+
# extra worker, made from the prepared test database.
|
|
58
|
+
def prepare_workers(jobs)
|
|
59
|
+
return unless defined?(::ActiveRecord::Base) && jobs > 1
|
|
60
|
+
|
|
61
|
+
test_configs.each do |db|
|
|
62
|
+
(1...jobs).each { |number| copy_database(db, self.class.worker_database(db.database, number)) }
|
|
63
|
+
end
|
|
64
|
+
disconnect
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Worker: point every connection pool (primary, and any cache, queue or
|
|
68
|
+
# connects_to databases, for every role and shard) at this worker's
|
|
69
|
+
# copy of its database.
|
|
70
|
+
def start_worker(number)
|
|
71
|
+
return unless defined?(::ActiveRecord::Base) && number.positive?
|
|
72
|
+
|
|
73
|
+
handler = ::ActiveRecord::Base.connection_handler
|
|
74
|
+
handler.connection_pool_list(:all).to_a.each do |pool|
|
|
75
|
+
config = pool.pool_config
|
|
76
|
+
db = pool.db_config
|
|
77
|
+
handler.establish_connection(db.configuration_hash.merge(database: self.class.worker_database(db.database,
|
|
78
|
+
number)),
|
|
79
|
+
owner_name: self.class.owner_of(config), role: config.role,
|
|
80
|
+
shard: config.shard)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.owner_of(pool_config)
|
|
85
|
+
return pool_config.connection_class if pool_config.respond_to?(:connection_class)
|
|
86
|
+
|
|
87
|
+
Object.const_get(pool_config.connection_descriptor.name)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.local_host?(host)
|
|
91
|
+
host.nil? || host.to_s.empty? || host.to_s.start_with?("/") || LOCAL_HOSTS.include?(host.to_s.downcase)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def check_databases
|
|
97
|
+
return unless defined?(::ActiveRecord::Base)
|
|
98
|
+
|
|
99
|
+
remote = ::ActiveRecord::Base.configurations.configs_for(env_name: "test").filter_map do |db|
|
|
100
|
+
host = db.configuration_hash[:host]
|
|
101
|
+
"#{db.name} (host #{host})" unless self.class.local_host?(host)
|
|
102
|
+
end
|
|
103
|
+
return if remote.empty?
|
|
104
|
+
|
|
105
|
+
raise Error, "rails integration refuses to run against non-local databases: #{remote.join(', ')}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def test_configs
|
|
109
|
+
::ActiveRecord::Base.configurations.configs_for(env_name: "test")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Postgres: CREATE DATABASE ... TEMPLATE (needs no open connections to
|
|
113
|
+
# the template, so it runs from the maintenance database). SQLite: a
|
|
114
|
+
# file copy. Other adapters are not supported for workers.
|
|
115
|
+
def copy_database(db, name)
|
|
116
|
+
case db.adapter
|
|
117
|
+
when /postg/
|
|
118
|
+
disconnect
|
|
119
|
+
copy_postgres(db, name)
|
|
120
|
+
when /sqlite/
|
|
121
|
+
# A database in WAL mode keeps recent writes in -wal/-shm files.
|
|
122
|
+
["", "-wal", "-shm"].each do |suffix|
|
|
123
|
+
FileUtils.cp("#{db.database}#{suffix}", "#{name}#{suffix}") if File.exist?("#{db.database}#{suffix}")
|
|
124
|
+
end
|
|
125
|
+
else
|
|
126
|
+
raise Error, "--jobs > 1 with Rails needs Postgres or SQLite (got #{db.adapter})"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Through a connection class of saboteur's own, so the app's
|
|
131
|
+
# connections never point at the maintenance database.
|
|
132
|
+
def copy_postgres(db, name)
|
|
133
|
+
admin = maintenance_record
|
|
134
|
+
admin.establish_connection(db.configuration_hash.merge(database: "postgres"))
|
|
135
|
+
admin.connection_pool.with_connection do |connection|
|
|
136
|
+
connection.execute("DROP DATABASE IF EXISTS #{connection.quote_table_name(name)}")
|
|
137
|
+
connection.execute("CREATE DATABASE #{connection.quote_table_name(name)} " \
|
|
138
|
+
"TEMPLATE #{connection.quote_table_name(db.database)}")
|
|
139
|
+
end
|
|
140
|
+
ensure
|
|
141
|
+
admin&.remove_connection
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# ActiveRecord names a connection's owner by class name, so the class
|
|
145
|
+
# needs a constant.
|
|
146
|
+
def maintenance_record
|
|
147
|
+
return self.class::MaintenanceRecord if self.class.const_defined?(:MaintenanceRecord, false)
|
|
148
|
+
|
|
149
|
+
self.class.const_set(:MaintenanceRecord, Class.new(::ActiveRecord::Base) { self.abstract_class = true })
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def disconnect
|
|
153
|
+
return unless defined?(::ActiveRecord::Base)
|
|
154
|
+
|
|
155
|
+
::ActiveRecord::Base.connection_handler.connection_pool_list(:all).each(&:disconnect!)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The parent disconnected before fork, so inherited pools hold no sockets.
|
|
159
|
+
# Disconnecting again in the child is idempotent and guarantees the child
|
|
160
|
+
# checks out a fresh connection of its own on first use.
|
|
161
|
+
def reconnect
|
|
162
|
+
disconnect
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
# Lifecycle callbacks declared in config.
|
|
6
|
+
#
|
|
7
|
+
# before_boot - parent, before the target app is loaded
|
|
8
|
+
# after_boot - parent, after the app and test files are loaded
|
|
9
|
+
# before_fork - parent, once per mutant, just before fork
|
|
10
|
+
# after_fork - child, right after fork (reconnect DBs, reseed RNG)
|
|
11
|
+
# around_mutant - child, wraps applying the mutant and running its tests
|
|
12
|
+
# worker_start - each parallel worker (--jobs N), with its number, before
|
|
13
|
+
# its first subject
|
|
14
|
+
class Hooks
|
|
15
|
+
NAMES = %i[before_boot after_boot before_fork after_fork worker_start].freeze
|
|
16
|
+
AROUND = :around_mutant
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@callbacks = Hash.new { |hash, key| hash[key] = [] }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def register(name, callable = nil, &block)
|
|
23
|
+
name = name.to_sym
|
|
24
|
+
raise ArgumentError, "unknown hook: #{name}" unless NAMES.include?(name) || name == AROUND
|
|
25
|
+
|
|
26
|
+
@callbacks[name] << (callable || block || raise(ArgumentError, "hook #{name} needs a callable"))
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run(name, *args)
|
|
31
|
+
raise ArgumentError, "use #around for #{AROUND}" if name.to_sym == AROUND
|
|
32
|
+
|
|
33
|
+
@callbacks[name.to_sym].each { |callback| callback.call(*args) }
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Each around_mutant callback receives the mutation and a proc that runs
|
|
38
|
+
# the remaining chain. The first registered callback is the outermost.
|
|
39
|
+
# Returns the block's result whatever the callbacks return; if a callback
|
|
40
|
+
# never calls the chain, the block does not run and nil is returned.
|
|
41
|
+
def around(mutation, &inner)
|
|
42
|
+
result = nil
|
|
43
|
+
body = -> { result = inner.call }
|
|
44
|
+
chain = @callbacks[AROUND].reverse.reduce(body) do |next_step, callback|
|
|
45
|
+
-> { callback.call(mutation, next_step) }
|
|
46
|
+
end
|
|
47
|
+
chain.call
|
|
48
|
+
result
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
# Survivors the team has accepted. YAML list of entries:
|
|
8
|
+
#
|
|
9
|
+
# - subject: "Person#adult?"
|
|
10
|
+
# mutation: "1a2b3c4d5e6f7a8b" # digest shown next to each survivor
|
|
11
|
+
# reason: "equivalent: age is always an integer"
|
|
12
|
+
#
|
|
13
|
+
# reason is mandatory; a file with a missing or blank reason does not load.
|
|
14
|
+
class IgnoreList
|
|
15
|
+
Entry = Data.define(:subject, :mutation, :reason)
|
|
16
|
+
|
|
17
|
+
attr_reader :entries
|
|
18
|
+
|
|
19
|
+
def self.load(path)
|
|
20
|
+
return new([]) unless path && File.file?(path)
|
|
21
|
+
|
|
22
|
+
data = YAML.safe_load_file(path) || []
|
|
23
|
+
raise ConfigError, "#{path}: expected a list of entries" unless data.is_a?(Array)
|
|
24
|
+
|
|
25
|
+
new(data.each_with_index.map { |raw, index| parse_entry(raw, path, index) })
|
|
26
|
+
rescue Psych::SyntaxError => e
|
|
27
|
+
raise ConfigError, "#{path}: #{e.message}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.parse_entry(raw, path, index)
|
|
31
|
+
raise ConfigError, "#{path}: entry #{index + 1} is not a mapping" unless raw.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
values = %w[subject mutation reason].to_h { |key| [key, raw[key].to_s.strip] }
|
|
34
|
+
missing = values.select { |_, value| value.empty? }.keys
|
|
35
|
+
raise ConfigError, "#{path}: entry #{index + 1} is missing #{missing.join(', ')}" if missing.any?
|
|
36
|
+
|
|
37
|
+
Entry.new(subject: values["subject"], mutation: values["mutation"], reason: values["reason"])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(entries)
|
|
41
|
+
@entries = entries
|
|
42
|
+
@index = entries.to_h { |entry| [[entry.subject, entry.mutation], entry] }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Marks matching survivors as ignored. Returns warnings for stale entries:
|
|
46
|
+
# entries whose subject was examined but whose mutation no longer exists.
|
|
47
|
+
def apply(results)
|
|
48
|
+
seen = {}
|
|
49
|
+
results.each do |result|
|
|
50
|
+
key = [result.subject.id, result.digest]
|
|
51
|
+
entry = @index[key]
|
|
52
|
+
next unless entry
|
|
53
|
+
|
|
54
|
+
seen[key] = true
|
|
55
|
+
result.ignored = entry.reason if result.survived?
|
|
56
|
+
end
|
|
57
|
+
examined = results.to_set { |result| result.subject.id }
|
|
58
|
+
entries.select { |entry| examined.include?(entry.subject) && !seen[[entry.subject, entry.mutation]] }
|
|
59
|
+
.map { |entry| "stale ignore entry #{entry.subject} #{entry.mutation}: no such mutation" }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Integration
|
|
6
|
+
TestCase = Data.define(:id, :file)
|
|
7
|
+
# executed_ids: tests that actually ran, so the runner can tell a broken
|
|
8
|
+
# filter (nothing ran, every mutant "survives") from a real pass.
|
|
9
|
+
# failure_classes: the exception class name each failed test died with,
|
|
10
|
+
# so a kill by a failed assertion can be told from a kill by a crash.
|
|
11
|
+
# failure_messages: the first line of each failure, so a verdict that
|
|
12
|
+
# does not hold up can say why the test failed.
|
|
13
|
+
RunResult = Data.define(:passed, :failed_ids, :executed_ids, :runtime, :failure_classes, :failure_messages) do
|
|
14
|
+
def initialize(passed:, failed_ids:, executed_ids:, runtime:, failure_classes: {}, failure_messages: {})
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
REGISTRY = {} # rubocop:disable Style/MutableConstant
|
|
20
|
+
|
|
21
|
+
def self.register(name, klass)
|
|
22
|
+
REGISTRY[name.to_s] = klass
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.fetch(name)
|
|
26
|
+
REGISTRY.fetch(name.to_s) { raise Saboteur::Error, "unknown integration: #{name}" }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Contract every test framework adapter implements.
|
|
30
|
+
#
|
|
31
|
+
# Lifecycle: #setup runs once in the parent after boot and loads test files
|
|
32
|
+
# without running any test. #run and #run_all are called inside a forked
|
|
33
|
+
# child; they must not exit the process and must report by return value,
|
|
34
|
+
# not by printing.
|
|
35
|
+
class Base
|
|
36
|
+
attr_reader :config
|
|
37
|
+
|
|
38
|
+
def initialize(config)
|
|
39
|
+
@config = config
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def setup
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Array<TestCase>]
|
|
47
|
+
def all_tests
|
|
48
|
+
raise NotImplementedError
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Runs the given tests, stopping at the first failure.
|
|
52
|
+
# @param ids [Array<String>] ids from #all_tests
|
|
53
|
+
# @return [RunResult]
|
|
54
|
+
def run(_ids)
|
|
55
|
+
raise NotImplementedError
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Runs every test, calling recorder.test_started(id, file) before and
|
|
59
|
+
# recorder.test_finished(id) after each one. Used to build the coverage map.
|
|
60
|
+
def run_all(_recorder)
|
|
61
|
+
raise NotImplementedError
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Files whose content decides which tests exist (for the cache key).
|
|
65
|
+
def test_files
|
|
66
|
+
[]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
require_relative "rspec"
|