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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/LICENSE +21 -0
  4. data/README.md +145 -0
  5. data/exe/saboteur +12 -0
  6. data/lib/saboteur/cli.rb +112 -0
  7. data/lib/saboteur/config.rb +133 -0
  8. data/lib/saboteur/engine/boot.rb +83 -0
  9. data/lib/saboteur/engine/candidates.rb +63 -0
  10. data/lib/saboteur/engine/coverage_store.rb +99 -0
  11. data/lib/saboteur/engine/diagnostics.rb +37 -0
  12. data/lib/saboteur/engine/diff_scope.rb +32 -0
  13. data/lib/saboteur/engine/distribution.rb +74 -0
  14. data/lib/saboteur/engine/result_cache.rb +93 -0
  15. data/lib/saboteur/engine/scheduler.rb +215 -0
  16. data/lib/saboteur/engine/subject_selection.rb +63 -0
  17. data/lib/saboteur/engine/suite_files.rb +82 -0
  18. data/lib/saboteur/engine/verdict_check.rb +78 -0
  19. data/lib/saboteur/engine.rb +133 -0
  20. data/lib/saboteur/execution/process_table.rb +177 -0
  21. data/lib/saboteur/execution/process_tree.rb +99 -0
  22. data/lib/saboteur/execution/protocol.rb +41 -0
  23. data/lib/saboteur/execution/reevaluator.rb +144 -0
  24. data/lib/saboteur/execution/runner.rb +191 -0
  25. data/lib/saboteur/execution/supervisor.rb +337 -0
  26. data/lib/saboteur/execution/worker_pool.rb +249 -0
  27. data/lib/saboteur/frameworks/rails.rb +166 -0
  28. data/lib/saboteur/hooks.rb +51 -0
  29. data/lib/saboteur/ignore_list.rb +62 -0
  30. data/lib/saboteur/integration/base.rb +72 -0
  31. data/lib/saboteur/integration/rspec.rb +143 -0
  32. data/lib/saboteur/mutation_source/base.rb +26 -0
  33. data/lib/saboteur/mutation_source/builtin.rb +232 -0
  34. data/lib/saboteur/mutation_source/edit.rb +18 -0
  35. data/lib/saboteur/mutation_source/mutation.rb +28 -0
  36. data/lib/saboteur/reporter/diff.rb +97 -0
  37. data/lib/saboteur/reporter/stryker.rb +116 -0
  38. data/lib/saboteur/reporter/terminal.rb +136 -0
  39. data/lib/saboteur/result.rb +134 -0
  40. data/lib/saboteur/selection/coverage_map.rb +113 -0
  41. data/lib/saboteur/selection/git_diff.rb +64 -0
  42. data/lib/saboteur/selection/recorder.rb +72 -0
  43. data/lib/saboteur/subject.rb +166 -0
  44. data/lib/saboteur/subject_finder.rb +98 -0
  45. data/lib/saboteur/version.rb +6 -0
  46. data/lib/saboteur.rb +51 -0
  47. 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"