saboteur 0.3.0 → 0.5.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 +280 -1
- data/README.md +635 -77
- data/exe/saboteur +6 -2
- data/lib/generators/saboteur/install/install_generator.rb +21 -0
- data/lib/saboteur/agent_skill.rb +46 -0
- data/lib/saboteur/baseline/acceptance.rb +218 -0
- data/lib/saboteur/baseline/command.rb +80 -0
- data/lib/saboteur/baseline/comparison.rb +105 -0
- data/lib/saboteur/baseline/completeness.rb +53 -0
- data/lib/saboteur/baseline/evidence.rb +114 -0
- data/lib/saboteur/baseline/identity.rb +78 -0
- data/lib/saboteur/baseline/manifest.rb +164 -0
- data/lib/saboteur/baseline/run_evidence.rb +117 -0
- data/lib/saboteur/baseline/workflow.rb +121 -0
- data/lib/saboteur/baseline_health.rb +1 -1
- data/lib/saboteur/cli/exit_status.rb +93 -0
- data/lib/saboteur/cli/run_reporting.rb +75 -0
- data/lib/saboteur/cli.rb +171 -63
- data/lib/saboteur/config.rb +101 -19
- data/lib/saboteur/config_file.rb +97 -0
- data/lib/saboteur/custom_map.rb +170 -0
- data/lib/saboteur/doctor.rb +57 -9
- data/lib/saboteur/engine/baseline_guard.rb +44 -5
- data/lib/saboteur/engine/boot.rb +9 -0
- data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
- data/lib/saboteur/engine/canary.rb +74 -0
- data/lib/saboteur/engine/candidate_selection.rb +18 -5
- data/lib/saboteur/engine/candidates.rb +59 -7
- data/lib/saboteur/engine/coverage_store.rb +53 -6
- data/lib/saboteur/engine/data_inputs.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +176 -2
- data/lib/saboteur/engine/diff_scope.rb +18 -0
- data/lib/saboteur/engine/distribution.rb +54 -13
- data/lib/saboteur/engine/result_cache.rb +10 -5
- data/lib/saboteur/engine/run_fingerprint.rb +40 -10
- data/lib/saboteur/engine/sandbox_policy.rb +108 -0
- data/lib/saboteur/engine/scheduler.rb +99 -36
- data/lib/saboteur/engine/subject_selection.rb +24 -1
- data/lib/saboteur/engine/suite_files.rb +8 -1
- data/lib/saboteur/engine/test_guess.rb +52 -0
- data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
- data/lib/saboteur/engine/verdict_check.rb +7 -6
- data/lib/saboteur/engine.rb +257 -22
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/reevaluator.rb +79 -14
- data/lib/saboteur/execution/runner.rb +120 -17
- data/lib/saboteur/execution/sandbox.rb +271 -0
- data/lib/saboteur/execution/stale_captures.rb +43 -0
- data/lib/saboteur/execution/supervisor.rb +113 -9
- data/lib/saboteur/execution/worker_pool.rb +20 -4
- data/lib/saboteur/execution/wrapper.rb +45 -0
- data/lib/saboteur/frameworks/rails.rb +329 -13
- data/lib/saboteur/gates.rb +83 -0
- data/lib/saboteur/help.rb +63 -23
- data/lib/saboteur/ignore_list.rb +76 -17
- data/lib/saboteur/inline_suppression.rb +65 -0
- data/lib/saboteur/install.rb +132 -0
- data/lib/saboteur/integration/base.rb +1 -0
- data/lib/saboteur/integration/focus_guard.rb +83 -0
- data/lib/saboteur/integration/rspec.rb +68 -4
- data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
- data/lib/saboteur/mutation_source/builtin.rb +30 -5
- data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
- data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
- data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +57 -0
- data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
- data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
- data/lib/saboteur/preview.rb +106 -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 +67 -0
- data/lib/saboteur/reporter/agent_incomplete.rb +60 -0
- data/lib/saboteur/reporter/agent_summary.rb +137 -0
- data/lib/saboteur/reporter/findings.rb +12 -19
- data/lib/saboteur/reporter/history_paths.rb +38 -0
- data/lib/saboteur/reporter/history_record.rb +132 -0
- data/lib/saboteur/reporter/html.rb +61 -2
- data/lib/saboteur/reporter/mutation_text.rb +36 -0
- data/lib/saboteur/reporter/next_steps.rb +30 -0
- data/lib/saboteur/reporter/palette.rb +37 -0
- data/lib/saboteur/reporter/score_history.rb +123 -0
- data/lib/saboteur/reporter/score_table.rb +57 -0
- data/lib/saboteur/reporter/stryker.rb +25 -5
- data/lib/saboteur/reporter/survivor_hints.rb +68 -0
- data/lib/saboteur/reporter/survivor_list.rb +126 -0
- data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
- data/lib/saboteur/reporter/terminal.rb +157 -42
- data/lib/saboteur/reporter/test_matrix.rb +57 -0
- data/lib/saboteur/result.rb +86 -10
- data/lib/saboteur/score.rb +89 -0
- data/lib/saboteur/selection/coverage_map.rb +119 -13
- data/lib/saboteur/selection/git_diff.rb +28 -1
- data/lib/saboteur/selection/recorder.rb +55 -13
- data/lib/saboteur/subject.rb +40 -4
- data/lib/saboteur/subject_finder.rb +62 -12
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +56 -0
- data/lib/saboteur.rb +10 -0
- metadata +58 -2
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require "coverage"
|
|
4
5
|
require "fileutils"
|
|
6
|
+
require "pathname"
|
|
7
|
+
require "English"
|
|
8
|
+
require "tmpdir"
|
|
5
9
|
|
|
6
10
|
module Saboteur
|
|
7
11
|
module Frameworks
|
|
8
|
-
# Opt-in Rails support (`rails
|
|
12
|
+
# Opt-in Rails support (`c.rails = true` in config/saboteur.rb).
|
|
9
13
|
#
|
|
10
14
|
# Refuses to run unless RAILS_ENV is test and every configured database is
|
|
11
15
|
# local, eager loads the app so every subject's owner is defined before
|
|
@@ -14,9 +18,23 @@ module Saboteur
|
|
|
14
18
|
class Rails
|
|
15
19
|
LOCAL_HOSTS = ["localhost", "127.0.0.1", "::1", "[::1]"].freeze
|
|
16
20
|
|
|
21
|
+
# How many blocking session names the template-copy error lists.
|
|
22
|
+
MAX_BLOCKER_NAMES = 5
|
|
23
|
+
|
|
24
|
+
# sqlite3-ruby's Backup#step returns raw SQLite status codes instead
|
|
25
|
+
# of raising, and finish discards its own: without an explicit check
|
|
26
|
+
# a partial destination would pass as a copy.
|
|
27
|
+
SQLITE_DONE = 101
|
|
28
|
+
SQLITE_BUSY = 5
|
|
29
|
+
SQLITE_LOCKED = 6
|
|
30
|
+
BACKUP_ATTEMPTS = 5
|
|
31
|
+
BACKUP_RETRY_DELAY = 0.05
|
|
32
|
+
|
|
17
33
|
def initialize(config, hooks)
|
|
18
34
|
@config = config
|
|
19
35
|
@hooks = hooks
|
|
36
|
+
@worker_copies = []
|
|
37
|
+
@memory_snapshots = {}
|
|
20
38
|
end
|
|
21
39
|
|
|
22
40
|
def before_boot
|
|
@@ -27,18 +45,31 @@ module Saboteur
|
|
|
27
45
|
# on macOS that goes through XPC, which crashes a forked process. The
|
|
28
46
|
# databases are local only (check_databases), so it buys nothing.
|
|
29
47
|
ENV["PGGSSENCMODE"] ||= "disable"
|
|
48
|
+
|
|
49
|
+
# Bytecode from Bootsnap's compile cache carries no coverage counters,
|
|
50
|
+
# so a file served from the cache never appears in Coverage.result.
|
|
51
|
+
# Turn the cache off for this process before config/boot loads
|
|
52
|
+
# Bootsnap; the load-path cache still works. Bootsnap checks
|
|
53
|
+
# DISABLE_<feature> by presence, so a value the user set disables the
|
|
54
|
+
# cache too. Files loaded during the eager load are also covered by
|
|
55
|
+
# eager_load_with_coverage_running.
|
|
56
|
+
ENV["DISABLE_BOOTSNAP_COMPILE_CACHE"] ||= "1"
|
|
30
57
|
end
|
|
31
58
|
|
|
32
59
|
def after_boot
|
|
33
60
|
unless defined?(::Rails.env)
|
|
34
61
|
raise Error,
|
|
35
|
-
"rails: true but Rails did not load (add config/environment to require
|
|
62
|
+
"rails: true but Rails did not load (add config/environment to require - for an engine, " \
|
|
63
|
+
"its dummy app's environment, such as test/dummy/config/environment.rb; only a dynamically " \
|
|
64
|
+
"created dummy app has no environment file, and there you require the helper that calls " \
|
|
65
|
+
"setup instead, such as DummyApp.setup in spec/rails_helper)"
|
|
36
66
|
end
|
|
37
67
|
raise Error, "rails integration refuses to run: Rails.env is #{::Rails.env}" unless ::Rails.env.test?
|
|
38
68
|
|
|
39
69
|
check_databases
|
|
40
|
-
|
|
70
|
+
eager_load_with_coverage_running
|
|
41
71
|
disconnect
|
|
72
|
+
snapshot_memory_databases
|
|
42
73
|
@hooks.register(:before_fork) { |_mutation| disconnect }
|
|
43
74
|
@hooks.register(:after_fork) { reconnect }
|
|
44
75
|
end
|
|
@@ -54,16 +85,58 @@ module Saboteur
|
|
|
54
85
|
end
|
|
55
86
|
|
|
56
87
|
# Parent, before workers start: one copy of each test database per
|
|
57
|
-
# extra worker, made from the prepared test database.
|
|
88
|
+
# extra worker, made from the prepared test database. Every attempted
|
|
89
|
+
# copy is tracked before it runs, so cleanup_workers drops them all,
|
|
90
|
+
# including the partial set when a copy failed.
|
|
58
91
|
def prepare_workers(jobs)
|
|
59
92
|
return unless defined?(::ActiveRecord::Base) && jobs > 1
|
|
60
93
|
|
|
94
|
+
@worker_copies = []
|
|
61
95
|
test_configs.each do |db|
|
|
62
|
-
|
|
96
|
+
# An in-memory SQLite database lives in the parent's connection;
|
|
97
|
+
# workers inherit it through fork, so there is nothing to copy.
|
|
98
|
+
next if self.class.memory_database?(db)
|
|
99
|
+
|
|
100
|
+
(1...jobs).each do |number|
|
|
101
|
+
name = self.class.worker_database(db.database, number)
|
|
102
|
+
# Track the target before the copy, not after it: an attempt
|
|
103
|
+
# that created the database and then failed (a post-CREATE
|
|
104
|
+
# error, a partial SQLite copy) must still be dropped at the
|
|
105
|
+
# end of the run. Dropping is idempotent (DROP IF EXISTS,
|
|
106
|
+
# rm -f), so tracking a copy that never happened costs nothing.
|
|
107
|
+
@worker_copies << [db, name]
|
|
108
|
+
copy_database(db, name)
|
|
109
|
+
end
|
|
63
110
|
end
|
|
64
111
|
disconnect
|
|
65
112
|
end
|
|
66
113
|
|
|
114
|
+
# Parent, after the run: drop every copy prepare_workers made, so runs
|
|
115
|
+
# do not collect one database per worker. Every copy is attempted even
|
|
116
|
+
# when a drop fails; the failures are reported together at the end.
|
|
117
|
+
def cleanup_workers
|
|
118
|
+
return if @worker_copies.empty? && @memory_snapshot_dir.nil?
|
|
119
|
+
|
|
120
|
+
failures = @worker_copies.filter_map do |db, name|
|
|
121
|
+
drop_database(db, name)
|
|
122
|
+
nil
|
|
123
|
+
rescue StandardError => e
|
|
124
|
+
"#{name}: #{e.message}"
|
|
125
|
+
end
|
|
126
|
+
@worker_copies = []
|
|
127
|
+
if @memory_snapshot_dir
|
|
128
|
+
FileUtils.rm_rf(@memory_snapshot_dir)
|
|
129
|
+
@memory_snapshot_dir = nil
|
|
130
|
+
@memory_snapshots = {}
|
|
131
|
+
end
|
|
132
|
+
begin
|
|
133
|
+
disconnect
|
|
134
|
+
rescue StandardError => e
|
|
135
|
+
failures << "disconnect: #{e.message}"
|
|
136
|
+
end
|
|
137
|
+
raise Error, "could not drop worker database copies: #{failures.join('; ')}" if failures.any?
|
|
138
|
+
end
|
|
139
|
+
|
|
67
140
|
# Worker: point every connection pool (primary, and any cache, queue or
|
|
68
141
|
# connects_to databases, for every role and shard) at this worker's
|
|
69
142
|
# copy of its database.
|
|
@@ -74,11 +147,16 @@ module Saboteur
|
|
|
74
147
|
handler.connection_pool_list(:all).to_a.each do |pool|
|
|
75
148
|
config = pool.pool_config
|
|
76
149
|
db = pool.db_config
|
|
150
|
+
# An in-memory SQLite database would be discarded by
|
|
151
|
+
# re-establishing: the worker keeps its own connection.
|
|
152
|
+
next if self.class.memory_database?(db)
|
|
153
|
+
|
|
77
154
|
handler.establish_connection(db.configuration_hash.merge(database: self.class.worker_database(db.database,
|
|
78
155
|
number)),
|
|
79
156
|
owner_name: self.class.owner_of(config), role: config.role,
|
|
80
157
|
shard: config.shard)
|
|
81
158
|
end
|
|
159
|
+
restore_memory_databases
|
|
82
160
|
end
|
|
83
161
|
|
|
84
162
|
def self.owner_of(pool_config)
|
|
@@ -87,12 +165,35 @@ module Saboteur
|
|
|
87
165
|
Object.const_get(pool_config.connection_descriptor.name)
|
|
88
166
|
end
|
|
89
167
|
|
|
168
|
+
# Only SQLite can be in-memory; every other adapter names a server.
|
|
169
|
+
def self.memory_database?(db)
|
|
170
|
+
db.adapter.to_s.match?(/sqlite/) && db.database.to_s == ":memory:"
|
|
171
|
+
end
|
|
172
|
+
|
|
90
173
|
def self.local_host?(host)
|
|
91
174
|
host.nil? || host.to_s.empty? || host.to_s.start_with?("/") || LOCAL_HOSTS.include?(host.to_s.downcase)
|
|
92
175
|
end
|
|
93
176
|
|
|
94
177
|
private
|
|
95
178
|
|
|
179
|
+
# Bootsnap before 1.25 skips its bytecode cache only while Coverage is
|
|
180
|
+
# running (1.25 and later skip it whenever Coverage is set up, suspended
|
|
181
|
+
# or not), and Coverage is suspended while the app boots (exe/saboteur
|
|
182
|
+
# sets it up suspended). Cached bytecode carries no coverage counters.
|
|
183
|
+
# Resume Coverage just for the eager load, so every app file compiles
|
|
184
|
+
# with counters. The coverage recorder clears the counts before the
|
|
185
|
+
# suite runs, so load-time hits are never credited to a test.
|
|
186
|
+
def eager_load_with_coverage_running
|
|
187
|
+
return ::Rails.application.eager_load! unless Coverage.state == :suspended
|
|
188
|
+
|
|
189
|
+
Coverage.resume
|
|
190
|
+
begin
|
|
191
|
+
::Rails.application.eager_load!
|
|
192
|
+
ensure
|
|
193
|
+
Coverage.suspend
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
96
197
|
def check_databases
|
|
97
198
|
return unless defined?(::ActiveRecord::Base)
|
|
98
199
|
|
|
@@ -118,29 +219,157 @@ module Saboteur
|
|
|
118
219
|
disconnect
|
|
119
220
|
copy_postgres(db, name)
|
|
120
221
|
when /sqlite/
|
|
121
|
-
|
|
122
|
-
["", "-wal", "-shm"].each do |suffix|
|
|
123
|
-
FileUtils.cp("#{db.database}#{suffix}", "#{name}#{suffix}") if File.exist?("#{db.database}#{suffix}")
|
|
124
|
-
end
|
|
222
|
+
copy_sqlite(db, name)
|
|
125
223
|
else
|
|
126
224
|
raise Error, "--jobs > 1 with Rails needs Postgres or SQLite (got #{db.adapter})"
|
|
127
225
|
end
|
|
128
226
|
end
|
|
129
227
|
|
|
228
|
+
# The mirror of copy_database. Adapters copy_database rejects never get
|
|
229
|
+
# copies, so there is nothing to drop for them.
|
|
230
|
+
def drop_database(db, name)
|
|
231
|
+
case db.adapter
|
|
232
|
+
when /postg/
|
|
233
|
+
disconnect
|
|
234
|
+
drop_postgres(db, name)
|
|
235
|
+
when /sqlite/
|
|
236
|
+
target = sqlite_absolute(name)
|
|
237
|
+
["", "-wal", "-shm"].each { |suffix| FileUtils.rm_f("#{target}#{suffix}") }
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# A relative sqlite path means something only to Rails: 7.1+ resolves
|
|
242
|
+
# it against Rails.root, not the process working directory. Resolve
|
|
243
|
+
# the source the same way, checkpoint so a live WAL database holds
|
|
244
|
+
# every committed write in its main file, then snapshot it through
|
|
245
|
+
# the backup API. A missing source is an error, not a skip: otherwise
|
|
246
|
+
# the worker starts from a fresh empty database and silently tests
|
|
247
|
+
# nothing.
|
|
248
|
+
def copy_sqlite(db, name)
|
|
249
|
+
source = sqlite_absolute(db.database)
|
|
250
|
+
unless File.exist?(source)
|
|
251
|
+
raise Error, "test database #{source} does not exist; run the test suite once to create it"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
checkpoint_sqlite(source)
|
|
255
|
+
backup_sqlite(source, sqlite_absolute(name))
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def sqlite_absolute(database)
|
|
259
|
+
return database if Pathname.new(database).absolute?
|
|
260
|
+
return database unless defined?(::Rails)
|
|
261
|
+
|
|
262
|
+
File.expand_path(database, ::Rails.root)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# wal_checkpoint(TRUNCATE) reports instead of raising: [busy, frames
|
|
266
|
+
# in the WAL, frames checkpointed]. A busy reader or an incomplete
|
|
267
|
+
# pass leaves committed writes outside the main file, and copying on
|
|
268
|
+
# would silently miss them - fail closed instead. Through a
|
|
269
|
+
# connection of saboteur's own, so the app's connections never point
|
|
270
|
+
# at the database being checkpointed.
|
|
271
|
+
def checkpoint_sqlite(source)
|
|
272
|
+
with_sqlite(source) do |database|
|
|
273
|
+
busy, frames, checkpointed = database.execute("PRAGMA wal_checkpoint(TRUNCATE)").first
|
|
274
|
+
return if busy.zero? && frames == checkpointed
|
|
275
|
+
|
|
276
|
+
raise Error, "could not checkpoint #{source} before copying it " \
|
|
277
|
+
"(busy: #{busy}, frames: #{frames}, checkpointed: #{checkpointed}); " \
|
|
278
|
+
"close the connections holding it and run again"
|
|
279
|
+
end
|
|
280
|
+
rescue SQLite3::BusyException => e
|
|
281
|
+
raise Error, "could not checkpoint #{source} before copying it (#{e.message}); " \
|
|
282
|
+
"close the connections holding it and run again"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# A byte copy of main + WAL + SHM is no snapshot: a write landing
|
|
286
|
+
# between the file copies leaves the set malformed. The backup API
|
|
287
|
+
# copies a consistent image and restarts when the source changes
|
|
288
|
+
# under it. The target starts clean so stale sidecars from an
|
|
289
|
+
# earlier byte copy cannot shadow the fresh image.
|
|
290
|
+
def backup_sqlite(source, target)
|
|
291
|
+
["", "-wal", "-shm"].each { |suffix| FileUtils.rm_f("#{target}#{suffix}") }
|
|
292
|
+
with_sqlite(source, readonly: true) do |src|
|
|
293
|
+
with_sqlite(target) do |dst|
|
|
294
|
+
backup = SQLite3::Backup.new(dst, "main", src, "main")
|
|
295
|
+
begin
|
|
296
|
+
step_backup(backup, target)
|
|
297
|
+
ensure
|
|
298
|
+
backup.finish
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# BUSY and LOCKED earn a bounded retry; DONE means the target holds
|
|
305
|
+
# every page; anything else fails the copy.
|
|
306
|
+
def step_backup(backup, target)
|
|
307
|
+
BACKUP_ATTEMPTS.times do
|
|
308
|
+
case (status = backup.step(-1))
|
|
309
|
+
when SQLITE_DONE then return
|
|
310
|
+
when SQLITE_BUSY, SQLITE_LOCKED
|
|
311
|
+
sleep(BACKUP_RETRY_DELAY)
|
|
312
|
+
else
|
|
313
|
+
raise Error, "could not copy the database to #{target}: sqlite backup failed with status #{status}"
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
raise Error, "could not copy the database to #{target}: still busy after #{BACKUP_ATTEMPTS} attempts"
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def with_sqlite(path, **)
|
|
320
|
+
require "sqlite3"
|
|
321
|
+
|
|
322
|
+
database = SQLite3::Database.new(path, **)
|
|
323
|
+
begin
|
|
324
|
+
yield database
|
|
325
|
+
ensure
|
|
326
|
+
database.close
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
130
330
|
# Through a connection class of saboteur's own, so the app's
|
|
131
331
|
# connections never point at the maintenance database.
|
|
132
332
|
def copy_postgres(db, name)
|
|
133
|
-
|
|
134
|
-
admin.establish_connection(db.configuration_hash.merge(database: "postgres"))
|
|
135
|
-
admin.connection_pool.with_connection do |connection|
|
|
333
|
+
with_maintenance_connection(db) do |connection|
|
|
136
334
|
connection.execute("DROP DATABASE IF EXISTS #{connection.quote_table_name(name)}")
|
|
335
|
+
refuse_busy_template(connection, db.database)
|
|
137
336
|
connection.execute("CREATE DATABASE #{connection.quote_table_name(name)} " \
|
|
138
337
|
"TEMPLATE #{connection.quote_table_name(db.database)}")
|
|
139
338
|
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def drop_postgres(db, name)
|
|
342
|
+
with_maintenance_connection(db) do |connection|
|
|
343
|
+
connection.execute("DROP DATABASE IF EXISTS #{connection.quote_table_name(name)}")
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Through a connection class of saboteur's own, so the app's
|
|
348
|
+
# connections never point at the maintenance database.
|
|
349
|
+
def with_maintenance_connection(db, &)
|
|
350
|
+
admin = maintenance_record
|
|
351
|
+
admin.establish_connection(db.configuration_hash.merge(database: "postgres"))
|
|
352
|
+
admin.connection_pool.with_connection(&)
|
|
140
353
|
ensure
|
|
141
354
|
admin&.remove_connection
|
|
142
355
|
end
|
|
143
356
|
|
|
357
|
+
# Postgres refuses CREATE DATABASE ... TEMPLATE while any other session
|
|
358
|
+
# is connected to the template, so name the blockers instead of dying
|
|
359
|
+
# mid-run with the raw error. Never terminates other sessions.
|
|
360
|
+
def refuse_busy_template(connection, database)
|
|
361
|
+
blockers = connection.select_values(<<~SQL)
|
|
362
|
+
SELECT application_name FROM pg_stat_activity
|
|
363
|
+
WHERE datname = #{connection.quote(database)} AND pid <> pg_backend_pid()
|
|
364
|
+
SQL
|
|
365
|
+
return if blockers.empty?
|
|
366
|
+
|
|
367
|
+
names = blockers.map { |app| app.to_s.empty? ? "unnamed" : app }.first(MAX_BLOCKER_NAMES)
|
|
368
|
+
raise Error, "cannot copy the test database #{database}: #{blockers.size} other session(s) connected " \
|
|
369
|
+
"(#{names.join(', ')})#{', ...' if blockers.size > names.size}. " \
|
|
370
|
+
"Stop other test runs, consoles or Spring, then run saboteur again."
|
|
371
|
+
end
|
|
372
|
+
|
|
144
373
|
# ActiveRecord names a connection's owner by class name, so the class
|
|
145
374
|
# needs a constant.
|
|
146
375
|
def maintenance_record
|
|
@@ -149,10 +378,15 @@ module Saboteur
|
|
|
149
378
|
self.class.const_set(:MaintenanceRecord, Class.new(::ActiveRecord::Base) { self.abstract_class = true })
|
|
150
379
|
end
|
|
151
380
|
|
|
381
|
+
# Disconnecting an in-memory SQLite pool destroys the database, so
|
|
382
|
+
# those pools stay connected everywhere saboteur disconnects: the
|
|
383
|
+
# schema loaded at boot survives, and workers inherit it via fork.
|
|
152
384
|
def disconnect
|
|
153
385
|
return unless defined?(::ActiveRecord::Base)
|
|
154
386
|
|
|
155
|
-
::ActiveRecord::Base.connection_handler.connection_pool_list(:all).each
|
|
387
|
+
::ActiveRecord::Base.connection_handler.connection_pool_list(:all).each do |pool|
|
|
388
|
+
pool.disconnect! unless self.class.memory_database?(pool.db_config)
|
|
389
|
+
end
|
|
156
390
|
end
|
|
157
391
|
|
|
158
392
|
# The parent disconnected before fork, so inherited pools hold no sockets.
|
|
@@ -160,6 +394,88 @@ module Saboteur
|
|
|
160
394
|
# checks out a fresh connection of its own on first use.
|
|
161
395
|
def reconnect
|
|
162
396
|
disconnect
|
|
397
|
+
restore_memory_databases
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Parent, once the app booted: an in-memory SQLite database survives
|
|
401
|
+
# only inside its own connection, and a forked child cannot keep it -
|
|
402
|
+
# ActiveRecord discards inherited pools and the sqlite3 gem closes
|
|
403
|
+
# writable handles in the child, so the next access there opens an
|
|
404
|
+
# empty database. Snapshot each in-memory database to a file the
|
|
405
|
+
# children can restore from.
|
|
406
|
+
def snapshot_memory_databases
|
|
407
|
+
return unless defined?(::ActiveRecord::Base)
|
|
408
|
+
|
|
409
|
+
pools = ::ActiveRecord::Base.connection_handler.connection_pool_list(:all)
|
|
410
|
+
memory_pools = pools.select { |pool| self.class.memory_database?(pool.db_config) }
|
|
411
|
+
return if memory_pools.empty?
|
|
412
|
+
|
|
413
|
+
@memory_snapshot_dir = Dir.mktmpdir("saboteur-memory-")
|
|
414
|
+
own_memory_snapshots
|
|
415
|
+
memory_pools.each_with_index do |pool, index|
|
|
416
|
+
path = File.join(@memory_snapshot_dir, "#{index}.sqlite3")
|
|
417
|
+
pool.with_connection do |connection|
|
|
418
|
+
file = SQLite3::Database.new(path)
|
|
419
|
+
with_backup(file, connection.raw_connection, close: file) { |backup| step_backup(backup, path) }
|
|
420
|
+
end
|
|
421
|
+
@memory_snapshots[pool.db_config] = path
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# The safety net for an abrupt process exit: Engine.call removes the
|
|
426
|
+
# snapshots at the run boundary on every normal path, so this handler
|
|
427
|
+
# fires only when the owner process dies first. Forked children
|
|
428
|
+
# inherit the handler but not the pid.
|
|
429
|
+
def own_memory_snapshots
|
|
430
|
+
owner = Process.pid
|
|
431
|
+
at_exit { cleanup_memory_snapshots(owner) }
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def cleanup_memory_snapshots(owner)
|
|
435
|
+
FileUtils.rm_rf(@memory_snapshot_dir) if Process.pid == owner && @memory_snapshot_dir
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Yields a backup and always returns both handles: exactly one finish
|
|
439
|
+
# per init (skipped only when backup_init itself raised, leaving no
|
|
440
|
+
# handle), and the database closes even when finish fails.
|
|
441
|
+
def with_backup(destination, source, close:)
|
|
442
|
+
backup = SQLite3::Backup.new(destination, "main", source, "main")
|
|
443
|
+
yield backup
|
|
444
|
+
ensure
|
|
445
|
+
begin
|
|
446
|
+
backup&.finish
|
|
447
|
+
ensure
|
|
448
|
+
close_without_masking(close)
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# A close can fail too, and a close that fails on the way out must
|
|
453
|
+
# not hide the error already in flight.
|
|
454
|
+
def close_without_masking(database)
|
|
455
|
+
in_flight = $ERROR_INFO
|
|
456
|
+
database.close
|
|
457
|
+
rescue SQLite3::Exception
|
|
458
|
+
raise unless in_flight
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Child: load the parent's snapshot into this process's own fresh
|
|
462
|
+
# in-memory database. The backup overwrites the destination, so
|
|
463
|
+
# restoring more than once is harmless.
|
|
464
|
+
def restore_memory_databases
|
|
465
|
+
return unless defined?(::ActiveRecord::Base)
|
|
466
|
+
return if @memory_snapshots.empty?
|
|
467
|
+
|
|
468
|
+
::ActiveRecord::Base.connection_handler.connection_pool_list(:all).each do |pool|
|
|
469
|
+
snapshot = @memory_snapshots[pool.db_config]
|
|
470
|
+
next unless snapshot
|
|
471
|
+
|
|
472
|
+
pool.with_connection do |connection|
|
|
473
|
+
source = SQLite3::Database.new(snapshot, readonly: true)
|
|
474
|
+
with_backup(connection.raw_connection, source, close: source) do |backup|
|
|
475
|
+
step_backup(backup, pool.db_config.database)
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
end
|
|
163
479
|
end
|
|
164
480
|
end
|
|
165
481
|
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
# CI gates on how many mutants ended as a timeout or an error. A limit is a
|
|
6
|
+
# count ("3") or a percentage of the executed mutants ("5%"); the gate fails
|
|
7
|
+
# only when the count is above it. Unset means no gate.
|
|
8
|
+
class Gates
|
|
9
|
+
Violation = Data.define(:cause, :message)
|
|
10
|
+
|
|
11
|
+
# config key => [mutant status, exit cause recorded in the JSON report, flag]
|
|
12
|
+
SETTINGS = {
|
|
13
|
+
"max_timeouts" => [:timeout, "maxTimeouts", "--max-timeouts"],
|
|
14
|
+
"max_errors" => [:error, "maxErrors", "--max-errors"]
|
|
15
|
+
}.freeze
|
|
16
|
+
LIMIT = /\A(\d+)(%?)\z/
|
|
17
|
+
|
|
18
|
+
# The normalized limit: nil, an Integer count, or a String ending in "%".
|
|
19
|
+
# A malformed value is a ConfigError that names the setting.
|
|
20
|
+
def self.limit(value, key)
|
|
21
|
+
return nil if value.nil?
|
|
22
|
+
|
|
23
|
+
match = LIMIT.match(value.to_s.strip)
|
|
24
|
+
raise ConfigError, "#{key} must be a count (3) or a percentage (5%), got #{value.inspect}" unless match
|
|
25
|
+
|
|
26
|
+
digits, suffix = match.captures
|
|
27
|
+
number = digits.to_i
|
|
28
|
+
raise ConfigError, "#{key} percentage must be at most 100" if suffix == "%" && number > 100
|
|
29
|
+
|
|
30
|
+
suffix.empty? ? number : "#{number}%"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The --max-timeouts and --max-errors flags; each value goes to the block with its config key.
|
|
34
|
+
def self.define_options(parser)
|
|
35
|
+
SETTINGS.each do |key, (status, _cause, flag)|
|
|
36
|
+
parser.on("#{flag} N", "Exit 1 when more than N mutants end as #{status} (N or N%)") do |value|
|
|
37
|
+
yield key.to_sym, value
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def initialize(config)
|
|
43
|
+
@limits = SETTINGS.keys.to_h { |key| [key, self.class.limit(config[key], key)] }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
attr_reader :cause
|
|
47
|
+
|
|
48
|
+
def any?
|
|
49
|
+
@limits.values.any?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# --max-errors judges errored mutants, so a threshold run tolerates them.
|
|
53
|
+
def judges_errors?
|
|
54
|
+
!@limits.fetch("max_errors").nil?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Prints each failed gate to err and remembers the first one's cause for the JSON report.
|
|
58
|
+
def failed?(report, err)
|
|
59
|
+
found = violations(report)
|
|
60
|
+
found.each { |violation| err.puts("saboteur: #{violation.message}") }
|
|
61
|
+
@cause = found.first&.cause
|
|
62
|
+
!@cause.nil?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def violations(report)
|
|
66
|
+
SETTINGS.filter_map do |key, (status, cause, flag)|
|
|
67
|
+
limit = @limits.fetch(key)
|
|
68
|
+
count = report.counts.fetch(status)
|
|
69
|
+
next if limit.nil? || !over?(count, limit, report.executed.size)
|
|
70
|
+
|
|
71
|
+
Violation.new(cause: cause, message: "#{count} #{status} mutants, above #{flag} #{limit}")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def over?(count, limit, executed)
|
|
78
|
+
return count > limit if limit.is_a?(Integer)
|
|
79
|
+
|
|
80
|
+
count * 100 > limit.to_i * executed
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|