mutineer 0.10.0 → 0.11.1
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 +47 -0
- data/README.md +30 -1
- data/lib/mutineer/cli.rb +83 -50
- data/lib/mutineer/config.rb +25 -14
- data/lib/mutineer/coverage_map.rb +27 -7
- data/lib/mutineer/daemon_client.rb +17 -2
- data/lib/mutineer/daemon_server.rb +93 -9
- data/lib/mutineer/external_backend.rb +13 -1
- data/lib/mutineer/isolation.rb +18 -8
- data/lib/mutineer/mutators/statement_removal.rb +12 -10
- data/lib/mutineer/rails_worker_db.rb +137 -0
- data/lib/mutineer/reporter.rb +45 -4
- data/lib/mutineer/runner.rb +221 -53
- data/lib/mutineer/version.rb +1 -1
- metadata +2 -1
|
@@ -20,17 +20,23 @@ module Mutineer
|
|
|
20
20
|
#
|
|
21
21
|
# Protocol (one JSON object per line, both directions):
|
|
22
22
|
# boot in : {"cmd":"boot","project_root":"...","boot":"config/environment",
|
|
23
|
-
# "load_paths":["test"],"framework":"minitest","rails":true}
|
|
23
|
+
# "load_paths":["test"],"framework":"minitest","rails":true,"schema":"db/schema.rb"}
|
|
24
24
|
# ready out: {"ready":true,"ruby":"3.3.6"} (or {"ready":false,"error":"..."} then exit)
|
|
25
|
-
# run in : {"id":N,"payload":{"code":"<ruby>","source_file":"app/models/order.rb"},
|
|
25
|
+
# run in : {"id":N,"worker":I,"payload":{"code":"<ruby>","source_file":"app/models/order.rb"},
|
|
26
26
|
# "tests":["test/models/order_test.rb"],"timeout":30}
|
|
27
27
|
# verdict : {"id":N,"verdict":"survived"|"killed"|"error"|"timeout"}
|
|
28
28
|
# quit in : {"cmd":"quit"}
|
|
29
29
|
#
|
|
30
|
+
# Worker isolation (#26/U5): when the app is Rails, each fork is routed to its own
|
|
31
|
+
# database `<db>-<worker>` via {RailsWorkerDb} BEFORE any test loads, so concurrent
|
|
32
|
+
# workers can't clobber each other's transactional fixtures. `worker` defaults to 0
|
|
33
|
+
# (serial). SQLite this pass; Postgres provisioning is U10.
|
|
34
|
+
#
|
|
30
35
|
# Verdict mapping (KTD-5, Phase-2a honest limit): child exit 0=survived (suite
|
|
31
|
-
# passed), 1=killed (suite failed), 2=error (child raised AROUND the test — load
|
|
32
|
-
# boot failure); parent-detected timeout. Tagging an in-TEST DB
|
|
33
|
-
# (
|
|
36
|
+
# passed), 1=killed (suite failed), 2=error (child raised AROUND the test — load,
|
|
37
|
+
# boot, or worker-DB routing failure); parent-detected timeout. Tagging an in-TEST DB
|
|
38
|
+
# error (one fired inside a test body, vs at routing time) as `error` rather than
|
|
39
|
+
# `killed` is a U6 concern — only observable under the concurrent gate.
|
|
34
40
|
module DaemonServer
|
|
35
41
|
# Poll interval (seconds) for the per-fork deadline wait loop.
|
|
36
42
|
POLL = 0.02
|
|
@@ -67,6 +73,14 @@ module Mutineer
|
|
|
67
73
|
end
|
|
68
74
|
break if req["cmd"] == "quit"
|
|
69
75
|
|
|
76
|
+
# #26/U7: build the coverage map app-side and ship it to the tool, which
|
|
77
|
+
# then selects covering tests per mutant. One-shot control message.
|
|
78
|
+
if req["cmd"] == "coverage"
|
|
79
|
+
output.puts(JSON.generate(build_coverage_map))
|
|
80
|
+
output.flush
|
|
81
|
+
next
|
|
82
|
+
end
|
|
83
|
+
|
|
70
84
|
output.puts(JSON.generate(run_mutant(req)))
|
|
71
85
|
output.flush
|
|
72
86
|
end
|
|
@@ -77,16 +91,24 @@ module Mutineer
|
|
|
77
91
|
# BOOT ONCE. chdir + require the app's boot file so the whole app is loaded and
|
|
78
92
|
# inherited by every fork. Never requires mutineer.
|
|
79
93
|
def boot!(cfg)
|
|
94
|
+
@cfg = cfg
|
|
80
95
|
@framework = cfg.fetch("framework", "minitest")
|
|
81
96
|
@source_dirs = Array(cfg["source_dirs"]).map { |d| File.expand_path(d) }
|
|
82
97
|
Dir.chdir(cfg["project_root"]) if cfg["project_root"]
|
|
83
98
|
ENV["RAILS_ENV"] ||= "test" if cfg["rails"]
|
|
84
99
|
Array(cfg["load_paths"]).each { |d| $LOAD_PATH.unshift(File.expand_path(d)) }
|
|
100
|
+
# #26/U7: start Coverage BEFORE the app loads, so booted source lines are
|
|
101
|
+
# instrumented — the map build (build_via_fork) forks this booted parent.
|
|
102
|
+
if cfg["coverage"]
|
|
103
|
+
require "coverage"
|
|
104
|
+
Coverage.start(lines: true)
|
|
105
|
+
end
|
|
85
106
|
# Clear any mutant tempfile a prior SIGKILLed timeout child orphaned in a
|
|
86
107
|
# source dir BEFORE the app boots — Zeitwerk would otherwise choke on the
|
|
87
108
|
# tempfile's non-constant name during autoload setup.
|
|
88
109
|
sweep_temps
|
|
89
110
|
require File.expand_path(cfg["boot"]) if cfg["boot"]
|
|
111
|
+
setup_worker_db(cfg) if cfg["rails"]
|
|
90
112
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
91
113
|
# Boot failed (bad boot path, app error) — tell the client and exit so it can
|
|
92
114
|
# surface a clean error rather than hang on the handshake.
|
|
@@ -95,17 +117,68 @@ module Mutineer
|
|
|
95
117
|
exit!(1)
|
|
96
118
|
end
|
|
97
119
|
|
|
120
|
+
# Load the per-worker DB adapter app-side (sibling gem file, by relative path so
|
|
121
|
+
# it bypasses the app bundle — like this daemon itself). No-op unless the app has
|
|
122
|
+
# ActiveRecord. Records the adapter + schema path so each fork can route to its
|
|
123
|
+
# own database (#26 isolation, U5). SQLite-only this pass; a non-SQLite config
|
|
124
|
+
# raises in the fork and reads as `error`, never a mis-routed verdict.
|
|
125
|
+
def setup_worker_db(cfg)
|
|
126
|
+
require_relative "rails_worker_db"
|
|
127
|
+
@worker_db = RailsWorkerDb.available? ? RailsWorkerDb : nil
|
|
128
|
+
schema = cfg["schema"] && File.expand_path(cfg["schema"])
|
|
129
|
+
@schema_path = schema if schema && File.exist?(schema)
|
|
130
|
+
# Schema is loaded once per worker slot on first use (not every mutant fork).
|
|
131
|
+
@schema_ready = {}
|
|
132
|
+
rescue LoadError => e
|
|
133
|
+
@errio.puts("[daemon] worker-DB routing unavailable: #{e.message}")
|
|
134
|
+
@worker_db = nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# #26/U7: build the coverage map app-side (Coverage was started at boot) and
|
|
138
|
+
# return it as `{map, failed_test_files}` for the tool to select covering tests.
|
|
139
|
+
# Capture forks route to worker 0's DB (isolated, serial). On any failure return
|
|
140
|
+
# an empty map + an error string — the tool then falls back to the full test set
|
|
141
|
+
# rather than mis-scoring everything as no_coverage.
|
|
142
|
+
def build_coverage_map
|
|
143
|
+
require_relative "coverage_map"
|
|
144
|
+
root = @cfg["project_root"] || Dir.pwd
|
|
145
|
+
cmap = CoverageMap.new(
|
|
146
|
+
source_paths: Array(@cfg["sources"]), test_paths: Array(@cfg["tests"]),
|
|
147
|
+
load_paths: Array(@cfg["load_paths"]), project_root: root,
|
|
148
|
+
boot_path: @cfg["boot"], framework: @framework, cache_dir: File.join(root, ".mutineer")
|
|
149
|
+
).build_via_fork(after_fork: coverage_after_fork)
|
|
150
|
+
{ "map" => cmap.map, "failed_test_files" => cmap.failed_test_files }
|
|
151
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
152
|
+
@errio.puts("[daemon] coverage build failed: #{e.class}: #{e.message}")
|
|
153
|
+
{ "map" => {}, "failed_test_files" => [], "error" => "#{e.class}: #{e.message}" }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Fork-safety hook for coverage capture: route each capture fork to worker 0's
|
|
157
|
+
# isolated DB (captures run serially, so one worker is enough). Nil when the app
|
|
158
|
+
# has no worker-DB adapter (non-Rails) — capture then runs as before.
|
|
159
|
+
def coverage_after_fork
|
|
160
|
+
return nil unless @worker_db
|
|
161
|
+
|
|
162
|
+
schema = @schema_path
|
|
163
|
+
-> { @worker_db.after_fork(0, schema) }
|
|
164
|
+
end
|
|
165
|
+
|
|
98
166
|
# Fork a child to run one mutant in isolation; decode its exit into a verdict.
|
|
99
167
|
def run_mutant(req)
|
|
100
168
|
timeout = req.fetch("timeout", 30)
|
|
169
|
+
worker = req.fetch("worker", 0)
|
|
170
|
+
# Load schema until the first killed/survived fork for this worker slot.
|
|
171
|
+
schema_for_fork = (@worker_db && @schema_path && !@schema_ready[worker]) ? @schema_path : nil
|
|
101
172
|
pid = fork do
|
|
102
|
-
# New process group so a per-fork timeout can SIGKILL the whole subtree
|
|
103
|
-
#
|
|
104
|
-
# test-framework output can never corrupt the IPC pipe (KTD-6).
|
|
173
|
+
# New process group so a per-fork timeout can SIGKILL the whole subtree,
|
|
174
|
+
# and silence the child's stdout so test output never corrupts the IPC pipe.
|
|
105
175
|
Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
|
|
106
176
|
$stdout.reopen(File::NULL, "w")
|
|
107
177
|
code =
|
|
108
178
|
begin
|
|
179
|
+
# Route THIS fork at its own worker database before any test loads.
|
|
180
|
+
# A routing failure raises here and is scored `error`, never a false verdict.
|
|
181
|
+
@worker_db&.after_fork(worker, schema_for_fork)
|
|
109
182
|
apply_payload(req["payload"])
|
|
110
183
|
run_tests(Array(req["tests"]))
|
|
111
184
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
@@ -115,6 +188,10 @@ module Mutineer
|
|
|
115
188
|
exit!(code)
|
|
116
189
|
end
|
|
117
190
|
verdict = wait_verdict(pid, timeout)
|
|
191
|
+
# Mark ready only when the child finished cleanly after schema load
|
|
192
|
+
# (killed/survived). Timeout can interrupt mid-load_schema; error is a
|
|
193
|
+
# routing failure — both leave the slot unready so the next fork reloads.
|
|
194
|
+
@schema_ready[worker] = true if schema_for_fork && %w[killed survived].include?(verdict)
|
|
118
195
|
# A SIGKILLed timeout child skipped its Tempfile unlink — sweep the orphan so
|
|
119
196
|
# it can't outlive the run or trip Zeitwerk on a later fork.
|
|
120
197
|
sweep_temps if verdict == "timeout"
|
|
@@ -151,7 +228,14 @@ module Mutineer
|
|
|
151
228
|
rescue Errno::ESRCH, Errno::EPERM
|
|
152
229
|
Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
153
230
|
end
|
|
154
|
-
|
|
231
|
+
begin
|
|
232
|
+
_reaped, status = Process.waitpid2(pid)
|
|
233
|
+
if status && status.exited? && !status.signaled?
|
|
234
|
+
return { 0 => "survived", 1 => "killed" }.fetch(status.exitstatus, "error")
|
|
235
|
+
end
|
|
236
|
+
rescue Errno::ECHILD
|
|
237
|
+
# already reaped
|
|
238
|
+
end
|
|
155
239
|
return "timeout"
|
|
156
240
|
end
|
|
157
241
|
sleep POLL
|
|
@@ -62,6 +62,12 @@ module Mutineer
|
|
|
62
62
|
Result.timeout
|
|
63
63
|
else # :exited
|
|
64
64
|
return Result.survived if code&.zero?
|
|
65
|
+
# Signal death (nil exitstatus) is infrastructure, not a suite assertion
|
|
66
|
+
# failure — match Isolation/daemon (error), do not inflate kill rate.
|
|
67
|
+
if code.nil?
|
|
68
|
+
warn output if verbose && !output.empty?
|
|
69
|
+
return Result.error("test-command terminated by signal")
|
|
70
|
+
end
|
|
65
71
|
|
|
66
72
|
warn output if verbose && !output.empty?
|
|
67
73
|
Result.killed
|
|
@@ -151,7 +157,13 @@ module Mutineer
|
|
|
151
157
|
rescue Errno::ESRCH, Errno::EPERM
|
|
152
158
|
Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
153
159
|
end
|
|
154
|
-
|
|
160
|
+
begin
|
|
161
|
+
_reaped, status = Process.waitpid2(pid)
|
|
162
|
+
# Finished cleanly between poll and kill — honor real exit code.
|
|
163
|
+
return [:exited, status.exitstatus] if status && status.exited? && !status.signaled?
|
|
164
|
+
rescue Errno::ECHILD
|
|
165
|
+
# already reaped
|
|
166
|
+
end
|
|
155
167
|
return [:timeout, nil]
|
|
156
168
|
end
|
|
157
169
|
sleep POLL
|
data/lib/mutineer/isolation.rb
CHANGED
|
@@ -31,6 +31,9 @@ module Mutineer
|
|
|
31
31
|
# @return [Mutineer::Result] result from the child process.
|
|
32
32
|
def self.run(timeout: DEFAULT_TIMEOUT)
|
|
33
33
|
pid = fork do
|
|
34
|
+
# Own process group so a timeout kill can reap grandchildren (match
|
|
35
|
+
# daemon/external backends). Best-effort: if setpgid fails, kill the pid.
|
|
36
|
+
Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
|
|
34
37
|
code = 0
|
|
35
38
|
begin
|
|
36
39
|
result = yield
|
|
@@ -48,20 +51,27 @@ module Mutineer
|
|
|
48
51
|
exit!(code)
|
|
49
52
|
end
|
|
50
53
|
|
|
51
|
-
# Single-threaded deadline poll
|
|
52
|
-
#
|
|
53
|
-
# shows the child is still alive past the deadline
|
|
54
|
-
# never hit a reaped/recycled pid. Timeout is a parent-side fact
|
|
55
|
-
# (deadline reached), not status.signaled? (which is true for ANY signal
|
|
56
|
-
# death, e.g. SIGSEGV).
|
|
54
|
+
# Single-threaded deadline poll: we are the ONLY caller of waitpid on this
|
|
55
|
+
# pid, so we never reap-then-kill. SIGKILL the process group only after
|
|
56
|
+
# WNOHANG shows the child is still alive past the deadline.
|
|
57
57
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
58
58
|
loop do
|
|
59
59
|
reaped, status = Process.waitpid2(pid, Process::WNOHANG)
|
|
60
60
|
return decode(status) if reaped
|
|
61
61
|
|
|
62
62
|
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
begin
|
|
64
|
+
Process.kill(:KILL, -pid)
|
|
65
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
66
|
+
Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
67
|
+
end
|
|
68
|
+
begin
|
|
69
|
+
_reaped, status = Process.waitpid2(pid)
|
|
70
|
+
# Child may have finished cleanly between WNOHANG and kill; honor it.
|
|
71
|
+
return decode(status) if status && status.exited? && !status.signaled?
|
|
72
|
+
rescue Errno::ECHILD
|
|
73
|
+
# already reaped
|
|
74
|
+
end
|
|
65
75
|
return Result.timeout
|
|
66
76
|
end
|
|
67
77
|
sleep 0.005
|
|
@@ -14,17 +14,19 @@ module Mutineer
|
|
|
14
14
|
# @return [void]
|
|
15
15
|
def visit_statements_node(node)
|
|
16
16
|
stmts = node.body
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
17
|
+
if stmts.length >= 2
|
|
18
|
+
stmts[0...-1].each do |stmt|
|
|
19
|
+
loc = stmt.location
|
|
20
|
+
@mutations << Mutation.new(
|
|
21
|
+
start_offset: loc.start_offset,
|
|
22
|
+
end_offset: loc.end_offset,
|
|
23
|
+
replacement: "nil",
|
|
24
|
+
operator: :statement_removal
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
27
|
end
|
|
28
|
+
# Nested statement lists (if/begin/while bodies) are separate StatementsNodes.
|
|
29
|
+
super
|
|
28
30
|
end
|
|
29
31
|
end
|
|
30
32
|
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutineer
|
|
4
|
+
# #26/#27 Phase 2b (U5) — per-worker database isolation for the daemon path.
|
|
5
|
+
#
|
|
6
|
+
# Loaded APP-SIDE by {DaemonServer} (a sibling gem file, pulled in by absolute path
|
|
7
|
+
# so it bypasses the app bundle — the same trick {DaemonClient} uses to run
|
|
8
|
+
# `daemon_server.rb` under a bundle that has no mutineer). It uses the app's OWN
|
|
9
|
+
# already-booted ActiveRecord and NEVER `require "active_record"` (R10/KTD-8): every
|
|
10
|
+
# method that touches AR first confirms {available?}, so the daemon core stays
|
|
11
|
+
# framework-agnostic and the gem keeps its zero-runtime-dependency promise.
|
|
12
|
+
#
|
|
13
|
+
# Isolation model (KTD-7): each parallel worker gets its OWN database so concurrent
|
|
14
|
+
# forks can't clobber each other's transactional fixtures (the measured #26
|
|
15
|
+
# corruption). {after_fork} runs inside a freshly-forked child and points that
|
|
16
|
+
# child's connection at the worker's database BEFORE any test loads; transactional
|
|
17
|
+
# fixtures then repopulate that isolated database per test.
|
|
18
|
+
#
|
|
19
|
+
# Scope: this pass ships the **SQLite** adapter (per-worker file, hermetic,
|
|
20
|
+
# spike-proven). Postgres per-worker DBs (`CREATE DATABASE <db>-<worker>`) are not
|
|
21
|
+
# implemented yet; a non-SQLite config raises a clear NotImplementedError rather
|
|
22
|
+
# than silently mis-routing.
|
|
23
|
+
#
|
|
24
|
+
# Honest limit (KTD-5): routing failures surface as `error` via {verify_connection!}.
|
|
25
|
+
# Re-raising an AR error that fires *inside a test body* past Minitest (so an in-test
|
|
26
|
+
# DB failure is `error`, not `killed`) is only observable under concurrent load and
|
|
27
|
+
# is deferred to U6 with the parallel gate — noted, not silently skipped.
|
|
28
|
+
module RailsWorkerDb
|
|
29
|
+
# True when the app has ActiveRecord loaded — the only condition under which any
|
|
30
|
+
# other method here may touch AR. Never triggers an autoload/require of AR itself.
|
|
31
|
+
#
|
|
32
|
+
# @return [Boolean]
|
|
33
|
+
def self.available?
|
|
34
|
+
defined?(ActiveRecord::Base) ? true : false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Derive a per-worker database path from a base path by inserting `-<worker>`
|
|
38
|
+
# before the extension. Pure string transform (no AR) so it is unit-testable in
|
|
39
|
+
# the zero-dep suite. `storage/test.sqlite3`, worker 1 -> `storage/test-1.sqlite3`.
|
|
40
|
+
#
|
|
41
|
+
# @param database [String] the base database path.
|
|
42
|
+
# @param worker [Integer] the worker slot index (0..N-1).
|
|
43
|
+
# @return [String] the per-worker database path.
|
|
44
|
+
def self.worker_database_path(database, worker)
|
|
45
|
+
ext = File.extname(database)
|
|
46
|
+
"#{database.delete_suffix(ext)}-#{worker}#{ext}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Build the AR connection config for one worker by copying the app's current
|
|
50
|
+
# (default test) config and swapping in the per-worker database path. SQLite only
|
|
51
|
+
# this pass — a non-SQLite adapter raises so the SQLite-first scope fails loud
|
|
52
|
+
# instead of mis-routing (Postgres is U10).
|
|
53
|
+
#
|
|
54
|
+
# @param worker [Integer] the worker slot index.
|
|
55
|
+
# @return [Hash] a symbol-keyed AR configuration hash for the worker database.
|
|
56
|
+
# @raise [NotImplementedError] when the app's database is non-SQLite or in-memory.
|
|
57
|
+
def self.worker_db_config(worker)
|
|
58
|
+
hash = ActiveRecord::Base.connection_db_config.configuration_hash
|
|
59
|
+
adapter = hash[:adapter].to_s
|
|
60
|
+
# U10 seam: the config-SHAPING below (per_worker_config) is already
|
|
61
|
+
# adapter-general — it derives correct SQLite *and* Postgres worker-DB names.
|
|
62
|
+
# What's gated is runtime PROVISIONING: SQLite files are created on connect,
|
|
63
|
+
# but Postgres needs an explicit `CREATE DATABASE` per worker (U10). Until that
|
|
64
|
+
# lands, refuse non-SQLite loudly rather than route to a database that doesn't
|
|
65
|
+
# exist until Postgres worker creation is implemented.
|
|
66
|
+
unless adapter.start_with?("sqlite")
|
|
67
|
+
raise NotImplementedError,
|
|
68
|
+
"worker-DB isolation currently provisions SQLite only (got adapter #{adapter.inspect}); " \
|
|
69
|
+
"Postgres per-worker provisioning is U10 (#26/#35) — use a SQLite test DB, or drop --jobs."
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
per_worker_config(hash, worker)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Pure config-shaping (no AR): given a connection config hash, return the
|
|
76
|
+
# per-worker variant with its database swapped to the worker's own name.
|
|
77
|
+
# Adapter-general — SQLite (`storage/test.sqlite3` → `storage/test-<w>.sqlite3`)
|
|
78
|
+
# and Postgres (`myapp_test` → `myapp_test-<w>`, Rails `parallelize` naming) both
|
|
79
|
+
# fall out of {worker_database_path}. Extracted + unit-tested so the Postgres
|
|
80
|
+
# SHAPE is proven ready for U10 without a live database.
|
|
81
|
+
#
|
|
82
|
+
# @param config_hash [Hash] a connection config hash (symbol or string keys).
|
|
83
|
+
# @param worker [Integer] the worker slot index.
|
|
84
|
+
# @return [Hash] the per-worker config (symbol keys), database swapped.
|
|
85
|
+
# @raise [NotImplementedError] for an in-memory or empty database (no per-worker split).
|
|
86
|
+
def self.per_worker_config(config_hash, worker)
|
|
87
|
+
hash = config_hash.transform_keys(&:to_sym)
|
|
88
|
+
database = hash[:database].to_s
|
|
89
|
+
if database.empty? || database == ":memory:"
|
|
90
|
+
raise NotImplementedError,
|
|
91
|
+
"worker-DB isolation needs a file/name-backed database (got #{database.inspect})."
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
hash.merge(database: worker_database_path(database, worker))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Child-side (after fork): route this process's ActiveRecord at the worker's own
|
|
98
|
+
# database and confirm it is reachable, so a routing failure reads as `error`
|
|
99
|
+
# (via the daemon's child rescue) rather than a false verdict. Loads the schema
|
|
100
|
+
# into the worker database when a schema path is given (idempotent — schema.rb
|
|
101
|
+
# runs with `force: true`), covering a fresh worker file.
|
|
102
|
+
#
|
|
103
|
+
# @param worker [Integer] the worker slot index.
|
|
104
|
+
# @param schema_path [String, nil] absolute path to `db/schema.rb`, or nil to skip.
|
|
105
|
+
# @return [void]
|
|
106
|
+
def self.after_fork(worker, schema_path = nil)
|
|
107
|
+
return unless available?
|
|
108
|
+
|
|
109
|
+
ActiveRecord::Base.establish_connection(worker_db_config(worker))
|
|
110
|
+
load_schema(schema_path) if schema_path
|
|
111
|
+
verify_connection!
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Load a Rails `schema.rb` into the current connection with output silenced
|
|
115
|
+
# (fork child stdout is already File::NULL; this is belt-and-braces).
|
|
116
|
+
#
|
|
117
|
+
# @param schema_path [String] absolute path to `db/schema.rb`.
|
|
118
|
+
# @return [void]
|
|
119
|
+
def self.load_schema(schema_path)
|
|
120
|
+
ActiveRecord::Migration.verbose = false if defined?(ActiveRecord::Migration)
|
|
121
|
+
original = $stdout
|
|
122
|
+
$stdout = File.open(File::NULL, "w")
|
|
123
|
+
load schema_path
|
|
124
|
+
ensure
|
|
125
|
+
$stdout.close unless $stdout.equal?(original)
|
|
126
|
+
$stdout = original
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Force a round-trip to the freshly-routed connection so a broken route fails HERE
|
|
130
|
+
# (→ `error`) instead of later masquerading as a test failure (→ false `killed`).
|
|
131
|
+
#
|
|
132
|
+
# @return [void]
|
|
133
|
+
def self.verify_connection!
|
|
134
|
+
ActiveRecord::Base.connection.execute("SELECT 1")
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
data/lib/mutineer/reporter.rb
CHANGED
|
@@ -69,12 +69,21 @@ module Mutineer
|
|
|
69
69
|
verdict(out, threshold) if threshold && threshold.positive?
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
-
# 0 pass / 1 below threshold. Usage errors (exit 2)
|
|
72
|
+
# 0 pass / 1 below threshold or untestable-with-errors. Usage errors (exit 2)
|
|
73
|
+
# are the CLI's job. When the score is nil (nothing killed or survived), pure
|
|
74
|
+
# no_coverage / all-ignored / empty still skip the gate; if any mutant was
|
|
75
|
+
# errored, timed out, or uncapturable, fail the gate so a broken harness
|
|
76
|
+
# cannot green CI under --threshold.
|
|
73
77
|
def exit_code(threshold:)
|
|
74
78
|
return 0 if threshold.nil? || threshold <= 0
|
|
75
79
|
|
|
76
80
|
score = @agg.mutation_score
|
|
77
|
-
|
|
81
|
+
if score.nil?
|
|
82
|
+
broken = @agg.errored_count + @agg.timeout_count + @agg.uncapturable_count
|
|
83
|
+
return 1 if @agg.total.positive? && broken.positive?
|
|
84
|
+
|
|
85
|
+
return 0 # pure no_coverage / ignored / empty — gate skipped
|
|
86
|
+
end
|
|
78
87
|
|
|
79
88
|
score >= threshold ? 0 : 1
|
|
80
89
|
end
|
|
@@ -380,12 +389,38 @@ module Mutineer
|
|
|
380
389
|
"#{@agg.ignored_count} ignored excluded"
|
|
381
390
|
if score.nil?
|
|
382
391
|
out.puts "Mutation score: N/A (no covered mutants)"
|
|
383
|
-
|
|
392
|
+
if broken_nil_score?
|
|
393
|
+
err.puts "[mutineer] no covered mutations (#{broken_nil_score_detail}); " \
|
|
394
|
+
"threshold gate fails under a positive --threshold (broken harness)."
|
|
395
|
+
else
|
|
396
|
+
err.puts "[mutineer] no covered mutations; mutation score is N/A and the threshold check is skipped."
|
|
397
|
+
end
|
|
384
398
|
else
|
|
385
399
|
out.puts "Mutation score: #{score}% (killed / (killed + survived); #{excluded})"
|
|
386
400
|
end
|
|
387
401
|
end
|
|
388
402
|
|
|
403
|
+
# True when nil score is due to errors/timeouts/uncapturable (gate must fail).
|
|
404
|
+
#
|
|
405
|
+
# @api private
|
|
406
|
+
# @return [Boolean]
|
|
407
|
+
def broken_nil_score?
|
|
408
|
+
@agg.total.positive? &&
|
|
409
|
+
(@agg.errored_count + @agg.timeout_count + @agg.uncapturable_count).positive?
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Human-readable counts for a broken nil-score run.
|
|
413
|
+
#
|
|
414
|
+
# @api private
|
|
415
|
+
# @return [String]
|
|
416
|
+
def broken_nil_score_detail
|
|
417
|
+
parts = []
|
|
418
|
+
parts << "#{@agg.errored_count} errored" if @agg.errored_count.positive?
|
|
419
|
+
parts << "#{@agg.timeout_count} timeout" if @agg.timeout_count.positive?
|
|
420
|
+
parts << "#{@agg.uncapturable_count} uncapturable" if @agg.uncapturable_count.positive?
|
|
421
|
+
parts.join(", ")
|
|
422
|
+
end
|
|
423
|
+
|
|
389
424
|
# #11: one line per source after the global summary, so a multi-source run
|
|
390
425
|
# shows which file is weak. Omitted for a single-source run — the global
|
|
391
426
|
# summary already says everything (ponytail: no redundant one-line block).
|
|
@@ -470,7 +505,13 @@ module Mutineer
|
|
|
470
505
|
# @return [void]
|
|
471
506
|
def verdict(out, threshold)
|
|
472
507
|
score = @agg.mutation_score
|
|
473
|
-
|
|
508
|
+
if score.nil?
|
|
509
|
+
if broken_nil_score?
|
|
510
|
+
out.puts "FAILED: no covered mutants (#{broken_nil_score_detail}); " \
|
|
511
|
+
"threshold #{threshold}% cannot pass with a broken harness"
|
|
512
|
+
end
|
|
513
|
+
return
|
|
514
|
+
end
|
|
474
515
|
|
|
475
516
|
if score >= threshold
|
|
476
517
|
out.puts "PASSED: #{score}% >= threshold #{threshold}%"
|