mutineer 1.4.0 → 1.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 +250 -0
- data/README.md +198 -27
- data/lib/mutineer/changed_lines.rb +24 -1
- data/lib/mutineer/cli.rb +112 -16
- data/lib/mutineer/config.rb +64 -13
- data/lib/mutineer/coverage_map.rb +161 -14
- data/lib/mutineer/daemon_backend.rb +3 -0
- data/lib/mutineer/daemon_client.rb +170 -13
- data/lib/mutineer/daemon_server.rb +2 -1
- data/lib/mutineer/external_backend.rb +2 -2
- data/lib/mutineer/isolation.rb +91 -6
- data/lib/mutineer/kill_channel.rb +284 -0
- data/lib/mutineer/kill_matrix.rb +88 -0
- data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
- data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
- data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
- data/lib/mutineer/minitest_integration.rb +27 -10
- data/lib/mutineer/mutant_id.rb +5 -4
- data/lib/mutineer/mutators/array_literal.rb +0 -7
- data/lib/mutineer/mutators/base.rb +63 -4
- data/lib/mutineer/mutators/chain_link.rb +0 -7
- data/lib/mutineer/mutators/collection_method.rb +1 -1
- data/lib/mutineer/mutators/condition_forcing.rb +0 -7
- data/lib/mutineer/mutators/operand_removal.rb +0 -7
- data/lib/mutineer/mutators/operator_assignment.rb +0 -7
- data/lib/mutineer/mutators/return_nil.rb +5 -11
- data/lib/mutineer/mutators/string_literal.rb +1 -1
- data/lib/mutineer/pairing.rb +222 -15
- data/lib/mutineer/parser.rb +12 -3
- data/lib/mutineer/project.rb +7 -0
- data/lib/mutineer/rails_worker_db.rb +1 -1
- data/lib/mutineer/reporter.rb +288 -52
- data/lib/mutineer/result.rb +37 -1
- data/lib/mutineer/runner.rb +133 -16
- data/lib/mutineer/statement_lines.rb +143 -0
- data/lib/mutineer/test_runners/minitest.rb +4 -2
- data/lib/mutineer/test_runners/rspec.rb +243 -3
- data/lib/mutineer/test_runners.rb +5 -3
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer/worker_pool.rb +4 -2
- data/lib/mutineer.rb +1 -0
- metadata +8 -3
|
@@ -10,6 +10,7 @@ require_relative "minitest_integration"
|
|
|
10
10
|
require_relative "test_runners"
|
|
11
11
|
require_relative "child_stdout"
|
|
12
12
|
require_relative "project_path"
|
|
13
|
+
require_relative "pairing"
|
|
13
14
|
|
|
14
15
|
module Mutineer
|
|
15
16
|
# Maps `(source_file, line) -> [test_files]` so each mutant runs only against
|
|
@@ -98,8 +99,11 @@ module Mutineer
|
|
|
98
99
|
# rather than a genuine coverage gap? True iff some capture failed this run
|
|
99
100
|
# AND this file got zero coverage from any successful capture AND a failed
|
|
100
101
|
# test file maps to it by the _test/_spec/test_ naming convention. Derived
|
|
101
|
-
#
|
|
102
|
-
#
|
|
102
|
+
# from already-persisted state (@map keys + @failed_test_files). A split
|
|
103
|
+
# name checks the longer source files that existed when that check first
|
|
104
|
+
# ran for this map. Adding or deleting one of those files changes the
|
|
105
|
+
# coverage digest, so the next run captures again instead of retargeting
|
|
106
|
+
# a stored failure.
|
|
103
107
|
#
|
|
104
108
|
# File-level, convention-based attribution. A line covered only by a failed
|
|
105
109
|
# test in an otherwise-covered file stays no_coverage (condition 2), and a
|
|
@@ -112,7 +116,7 @@ module Mutineer
|
|
|
112
116
|
rel = relativize(absolute(file))
|
|
113
117
|
return false if covered_source_files.include?(rel)
|
|
114
118
|
|
|
115
|
-
|
|
119
|
+
failed_test_blames?(rel)
|
|
116
120
|
end
|
|
117
121
|
|
|
118
122
|
# Per-method taint. A mutant on a line whose enclosing method got zero
|
|
@@ -131,7 +135,7 @@ module Mutineer
|
|
|
131
135
|
return false if @failed_test_files.empty?
|
|
132
136
|
|
|
133
137
|
rel = relativize(absolute(file))
|
|
134
|
-
return false unless
|
|
138
|
+
return false unless failed_test_blames?(rel)
|
|
135
139
|
|
|
136
140
|
line_range.none? { |ln| @map.key?("#{rel}:#{ln}") }
|
|
137
141
|
end
|
|
@@ -143,17 +147,119 @@ module Mutineer
|
|
|
143
147
|
@map.keys.map { |k| k.rpartition(":").first }.to_set
|
|
144
148
|
end
|
|
145
149
|
|
|
146
|
-
#
|
|
147
|
-
#
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
150
|
+
# True when a failed test file pairs with `source_rel` by convention.
|
|
151
|
+
# Exact `_test` / `_spec` / `test_` names match by basename, as before.
|
|
152
|
+
# A split `<name>_*_test.rb` matches only in the mirrored test directory,
|
|
153
|
+
# and only when no longer source file owns that name (#87). The answer
|
|
154
|
+
# is kept for this map, so a later delete does not move the failure.
|
|
155
|
+
#
|
|
156
|
+
# @param source_rel [String] project-relative source path.
|
|
157
|
+
# @return [Boolean]
|
|
158
|
+
def failed_test_blames?(source_rel)
|
|
159
|
+
@blame_for ||= {}
|
|
160
|
+
return @blame_for[source_rel] if @blame_for.key?(source_rel)
|
|
161
|
+
|
|
162
|
+
@blame_for[source_rel] = exact_failed_test?(source_rel) || split_failed_test?(source_rel)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# True when a failed exact `_test` / `_spec` / `test_` file pairs with
|
|
166
|
+
# this source. An `app/` or `lib/` source only accepts a `test/` or
|
|
167
|
+
# `spec/` file from its mirrored directory, so
|
|
168
|
+
# `test/foo/bar_upsert_test.rb` does not taint `app/other/bar_upsert.rb`.
|
|
169
|
+
# Any other source still matches by basename. A fixture under
|
|
170
|
+
# `test/fixtures/` and an explicit test both name their source that way.
|
|
171
|
+
#
|
|
172
|
+
# @param source_rel [String] project-relative source path.
|
|
173
|
+
# @return [Boolean]
|
|
174
|
+
def exact_failed_test?(source_rel)
|
|
175
|
+
name = File.basename(source_rel, ".rb")
|
|
176
|
+
base, lib = Pairing.logical_path(source_rel)
|
|
177
|
+
@failed_test_files.any? do |test_path|
|
|
178
|
+
next false unless failed_test_target_name(test_path) == name
|
|
179
|
+
|
|
180
|
+
rel = relativize(test_path)
|
|
181
|
+
dir = File.dirname(rel)
|
|
182
|
+
if app_or_lib_source?(source_rel) && conventional_test_dir?(dir)
|
|
183
|
+
mirrored_test_dir?(rel, base, lib) || spec_mirror?(rel, base, lib)
|
|
184
|
+
else
|
|
185
|
+
true
|
|
155
186
|
end
|
|
156
|
-
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# True when pairing gives this source a mirrored test directory.
|
|
191
|
+
#
|
|
192
|
+
# @param source_rel [String] project-relative source path.
|
|
193
|
+
# @return [Boolean]
|
|
194
|
+
def app_or_lib_source?(source_rel)
|
|
195
|
+
source_rel.start_with?("app/", "lib/")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Basename a failed test file would pair with, before the directory check.
|
|
199
|
+
#
|
|
200
|
+
# @param test_path [String]
|
|
201
|
+
# @return [String]
|
|
202
|
+
def failed_test_target_name(test_path)
|
|
203
|
+
name = File.basename(test_path, ".rb")
|
|
204
|
+
case name
|
|
205
|
+
when /_(test|spec)\z/ then name.sub(/_(test|spec)\z/, "")
|
|
206
|
+
when "test_helper" then name
|
|
207
|
+
else name.delete_prefix("test_")
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# True when `dir` is `test`, `spec`, or a directory under one of them.
|
|
212
|
+
#
|
|
213
|
+
# @param dir [String] project-relative directory.
|
|
214
|
+
# @return [Boolean]
|
|
215
|
+
def conventional_test_dir?(dir)
|
|
216
|
+
dir == "test" || dir == "spec" || dir.start_with?("test/", "spec/")
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# True when a failed split test file pairs with this source. Directory and
|
|
220
|
+
# the longer-source check match {Pairing}, so a test outside the mirror, or
|
|
221
|
+
# one owned by `user_session.rb`, does not taint `user.rb`.
|
|
222
|
+
#
|
|
223
|
+
# @param source_rel [String] project-relative source path.
|
|
224
|
+
# @return [Boolean]
|
|
225
|
+
def split_failed_test?(source_rel)
|
|
226
|
+
base, lib = Pairing.logical_path(source_rel)
|
|
227
|
+
name = File.basename(base)
|
|
228
|
+
@failed_test_files.any? do |t|
|
|
229
|
+
test_rel = relativize(t)
|
|
230
|
+
entry = File.basename(test_rel)
|
|
231
|
+
next false unless Pairing.split_entry?(entry, name)
|
|
232
|
+
next false unless mirrored_test_dir?(test_rel, base, lib)
|
|
233
|
+
next false if Pairing.claimed_by_longer_source?(@project_root, base, entry, File.dirname(test_rel))
|
|
234
|
+
|
|
235
|
+
true
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# True when `test_rel` sits in a mirrored test directory for `base`.
|
|
240
|
+
#
|
|
241
|
+
# @param test_rel [String] project-relative test path.
|
|
242
|
+
# @param base [String] logical source path without extension.
|
|
243
|
+
# @param lib [Boolean] whether the source originated from lib/.
|
|
244
|
+
# @return [Boolean]
|
|
245
|
+
def mirrored_test_dir?(test_rel, base, lib)
|
|
246
|
+
dirs = [Pairing.mirror_dir("test", base)]
|
|
247
|
+
dirs << Pairing.mirror_dir("test/lib", base) if lib
|
|
248
|
+
dirs.include?(File.dirname(test_rel))
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# True when `test_rel` sits in a mirrored spec directory for `base`.
|
|
252
|
+
# Split Minitest names stay in {#mirrored_test_dir?}. An exact `_spec`
|
|
253
|
+
# file uses this directory.
|
|
254
|
+
#
|
|
255
|
+
# @param test_rel [String] project-relative test path.
|
|
256
|
+
# @param base [String] logical source path without extension.
|
|
257
|
+
# @param lib [Boolean] whether the source originated from lib/.
|
|
258
|
+
# @return [Boolean]
|
|
259
|
+
def spec_mirror?(test_rel, base, lib)
|
|
260
|
+
dirs = [Pairing.mirror_dir("spec", base)]
|
|
261
|
+
dirs << Pairing.mirror_dir("spec/lib", base) if lib
|
|
262
|
+
dirs.include?(File.dirname(test_rel))
|
|
157
263
|
end
|
|
158
264
|
|
|
159
265
|
# Shared cache dance for both build paths: hit the digest-keyed cache, else
|
|
@@ -809,11 +915,52 @@ module Mutineer
|
|
|
809
915
|
digest_group(d, "source", @source_paths)
|
|
810
916
|
digest_group(d, "test", @test_paths)
|
|
811
917
|
digest_group(d, "boot", [boot_digest_path]) if @boot_path
|
|
918
|
+
ownership_paths.each do |rel|
|
|
919
|
+
d.update("owner\0")
|
|
920
|
+
d.update(rel)
|
|
921
|
+
d.update("\0")
|
|
922
|
+
end
|
|
812
923
|
@load_paths.sort.each { |lp| d.update("loadpath\0#{lp}\0") }
|
|
813
924
|
d.update("framework\0#{@framework}\0")
|
|
814
925
|
d.hexdigest
|
|
815
926
|
end
|
|
816
927
|
|
|
928
|
+
# Longer source files that can take a split test from a configured source.
|
|
929
|
+
# Only their presence is digested. A content edit of a sibling does not
|
|
930
|
+
# change pairing, and it does not rebuild coverage. Each directory is
|
|
931
|
+
# listed once, even when many sources share it.
|
|
932
|
+
#
|
|
933
|
+
# @return [Array<String>] project-relative paths, sorted.
|
|
934
|
+
def ownership_paths
|
|
935
|
+
root = File.expand_path(@project_root)
|
|
936
|
+
listings = {}
|
|
937
|
+
paths = @source_paths.flat_map do |source|
|
|
938
|
+
rel = relativize(absolute(source))
|
|
939
|
+
next [] if rel.start_with?("/")
|
|
940
|
+
|
|
941
|
+
base, = Pairing.logical_path(rel)
|
|
942
|
+
name = File.basename(base)
|
|
943
|
+
dir = File.dirname(base)
|
|
944
|
+
folders = [dir == "." ? nil : dir]
|
|
945
|
+
%w[app lib].each { |prefix| folders << (dir == "." ? prefix : File.join(prefix, dir)) }
|
|
946
|
+
folders.uniq.flat_map do |folder|
|
|
947
|
+
abs = folder ? File.join(root, folder) : root
|
|
948
|
+
entries = listings.fetch(abs) do
|
|
949
|
+
listings[abs] = File.directory?(abs) ? Dir.children(abs) : []
|
|
950
|
+
end
|
|
951
|
+
entries.filter_map do |entry|
|
|
952
|
+
next unless entry.end_with?(".rb")
|
|
953
|
+
|
|
954
|
+
stem = entry.delete_suffix(".rb")
|
|
955
|
+
next unless stem.start_with?("#{name}_") && stem.length > name.length
|
|
956
|
+
|
|
957
|
+
folder ? File.join(folder, entry) : entry
|
|
958
|
+
end
|
|
959
|
+
end
|
|
960
|
+
end
|
|
961
|
+
paths.uniq.sort
|
|
962
|
+
end
|
|
963
|
+
|
|
817
964
|
# boot_path is a require-style path (e.g. "config/environment", no extension);
|
|
818
965
|
# resolve it to the real file for reading, appending ".rb" when needed.
|
|
819
966
|
def boot_digest_path
|
|
@@ -279,6 +279,7 @@ module Mutineer
|
|
|
279
279
|
project_root: config.project_root,
|
|
280
280
|
boot: File.expand_path(config.boot || "config/environment", config.project_root),
|
|
281
281
|
load_paths: Runner.test_load_roots(abs_tests),
|
|
282
|
+
cache_dir: File.expand_path(config.cache_dir, config.project_root),
|
|
282
283
|
source_dirs: Runner.source_dirs(config), # so the daemon can sweep orphan mutant temps
|
|
283
284
|
framework: config.framework,
|
|
284
285
|
rails: config.rails,
|
|
@@ -290,6 +291,8 @@ module Mutineer
|
|
|
290
291
|
# instrumentation/memory across every mutant fork). `sources`/`tests` are the
|
|
291
292
|
# map-build inputs.
|
|
292
293
|
coverage: coverage,
|
|
294
|
+
# The map-building daemon's capture limit (--capture-timeout); nil = default.
|
|
295
|
+
capture_timeout: config.capture_timeout,
|
|
293
296
|
sources: config.sources.map { |s| File.expand_path(s, config.project_root) },
|
|
294
297
|
tests: abs_tests
|
|
295
298
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "open3"
|
|
5
|
+
require_relative "external_backend"
|
|
5
6
|
|
|
6
7
|
module Mutineer
|
|
7
8
|
# Raised when the daemon cannot be booted or is gone for good: a bad boot path, an
|
|
@@ -13,10 +14,12 @@ module Mutineer
|
|
|
13
14
|
|
|
14
15
|
# Tool-side handle for the app-side daemon.
|
|
15
16
|
#
|
|
16
|
-
# Spawns `daemon_server.rb` UNDER THE APP'S BUNDLE/RUBY
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
17
|
+
# Spawns `daemon_server.rb` UNDER THE APP'S BUNDLE/RUBY. The child gets the
|
|
18
|
+
# environment from before the tool's Bundler activated, with
|
|
19
|
+
# <tt>unsetenv_others</tt>, because Open3 keeps a key the hash omits. The
|
|
20
|
+
# daemon file is loaded by absolute path with `-r`, which bypasses the app
|
|
21
|
+
# bundle that has no mutineer. The client completes the ready handshake,
|
|
22
|
+
# then ships per-mutant payloads and reads structured verdicts.
|
|
20
23
|
# If the daemon dies mid-run it respawns (bounded) and marks the in-flight
|
|
21
24
|
# mutant `error` rather than corrupting the run. Reuses the cleaned-env spawn
|
|
22
25
|
# and stderr-drain proven in the spike driver and the spawn discipline of
|
|
@@ -26,11 +29,15 @@ module Mutineer
|
|
|
26
29
|
DAEMON_PATH = File.expand_path("daemon_server.rb", __dir__)
|
|
27
30
|
# How many times to respawn a crashing daemon before aborting the run.
|
|
28
31
|
MAX_RESTARTS = 3
|
|
32
|
+
# Bundler's marker for a variable that was unset before it activated.
|
|
33
|
+
BUNDLER_UNSET = "BUNDLER_ENVIRONMENT_PRESERVER_INTENTIONALLY_NIL"
|
|
29
34
|
|
|
30
35
|
# @param boot [Hash] boot config sent to the daemon: project_root, boot,
|
|
31
36
|
# load_paths, framework, rails.
|
|
32
37
|
# @param app_root [String] directory to spawn the daemon in (the app root).
|
|
33
|
-
# @param ruby_version [String, nil] RBENV_VERSION for the app's Ruby
|
|
38
|
+
# @param ruby_version [String, nil] RBENV_VERSION for the app's Ruby.
|
|
39
|
+
# nil keeps a pin already in the environment. That pin is often the
|
|
40
|
+
# Ruby that started the tool.
|
|
34
41
|
# @param gemfile [String, nil] BUNDLE_GEMFILE for the app's bundle (nil = app_root/Gemfile).
|
|
35
42
|
# @param errio [IO] where daemon stderr is drained.
|
|
36
43
|
def initialize(boot:, app_root:, ruby_version: nil, gemfile: nil, errio: $stderr)
|
|
@@ -117,25 +124,174 @@ module Mutineer
|
|
|
117
124
|
|
|
118
125
|
private
|
|
119
126
|
|
|
120
|
-
#
|
|
121
|
-
#
|
|
127
|
+
# Full environment for the daemon child. Spawn with +unsetenv_others+ so a
|
|
128
|
+
# key deleted here does not leak back from the parent. Undo only what
|
|
129
|
+
# Bundler saved before it activated, then point the child at the target app.
|
|
130
|
+
#
|
|
131
|
+
# @api private
|
|
132
|
+
# @return [Hash{String => String}]
|
|
122
133
|
def app_env
|
|
123
|
-
env =
|
|
134
|
+
env = restored_user_env
|
|
135
|
+
scrub_managed_ruby_bins!(env)
|
|
136
|
+
env.delete("BUNDLER_SETUP")
|
|
124
137
|
env["BUNDLE_GEMFILE"] = @gemfile
|
|
125
138
|
env["RBENV_VERSION"] = @ruby_version if @ruby_version
|
|
126
|
-
env["RAILS_ENV"]
|
|
139
|
+
env["RAILS_ENV"] = "test" if rails_boot? && !env.key?("RAILS_ENV")
|
|
140
|
+
env
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Environment with every <tt>BUNDLER_ORIG_*</tt> value put back. Bundler
|
|
144
|
+
# writes one saved key per variable it replaced. Restoring all of them
|
|
145
|
+
# undoes the tool bundle without a copied key list.
|
|
146
|
+
#
|
|
147
|
+
# @api private
|
|
148
|
+
# @return [Hash{String => String}]
|
|
149
|
+
def restored_user_env
|
|
150
|
+
env = ENV.to_h
|
|
151
|
+
restored = []
|
|
152
|
+
env.keys.each do |saved_key|
|
|
153
|
+
next unless saved_key.start_with?("BUNDLER_ORIG_")
|
|
154
|
+
|
|
155
|
+
key = saved_key.delete_prefix("BUNDLER_ORIG_")
|
|
156
|
+
saved = env.delete(saved_key)
|
|
157
|
+
restored << key
|
|
158
|
+
if saved == BUNDLER_UNSET
|
|
159
|
+
env.delete(key)
|
|
160
|
+
else
|
|
161
|
+
env[key] = saved
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
# A parent require must not run in the app, even when Bundler saved it.
|
|
165
|
+
%w[RUBYOPT RUBYLIB].each { |key| env.delete(key) }
|
|
166
|
+
# No saved original means the value belongs to the tool process.
|
|
167
|
+
%w[GEM_HOME GEM_PATH BUNDLE_PATH BUNDLE_WITHOUT BUNDLER_VERSION].each do |key|
|
|
168
|
+
env.delete(key) unless restored.include?(key)
|
|
169
|
+
end
|
|
127
170
|
env
|
|
128
171
|
end
|
|
129
172
|
|
|
173
|
+
# Drop rbenv and asdf version bins so shims can select the app Ruby.
|
|
174
|
+
# Leave those bins in place when no shim directory exists, so +bundle+
|
|
175
|
+
# stays on PATH. Leave chruby bins in place.
|
|
176
|
+
#
|
|
177
|
+
# @api private
|
|
178
|
+
# @param env [Hash{String => String}]
|
|
179
|
+
# @return [void]
|
|
180
|
+
def scrub_managed_ruby_bins!(env)
|
|
181
|
+
parts = env["PATH"].to_s.split(File::PATH_SEPARATOR)
|
|
182
|
+
rbenv = rbenv_shim_dirs(env)
|
|
183
|
+
asdf = asdf_shim_dirs(env)
|
|
184
|
+
dropped_rbenv = false
|
|
185
|
+
dropped_asdf = false
|
|
186
|
+
kept = parts.reject do |part|
|
|
187
|
+
if rbenv_version_bin?(part, env) && rbenv.any?
|
|
188
|
+
dropped_rbenv = true
|
|
189
|
+
elsif asdf_version_bin?(part, env) && asdf.any?
|
|
190
|
+
dropped_asdf = true
|
|
191
|
+
else
|
|
192
|
+
false
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
return if kept.size == parts.size
|
|
196
|
+
|
|
197
|
+
shims = []
|
|
198
|
+
shims.concat(rbenv) if dropped_rbenv
|
|
199
|
+
shims.concat(asdf) if dropped_asdf
|
|
200
|
+
env["PATH"] = (shims + kept).uniq.join(File::PATH_SEPARATOR)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Existing rbenv shim directory. +RBENV_ROOT+ wins. Otherwise the home
|
|
204
|
+
# directory's <tt>.rbenv</tt> is used.
|
|
205
|
+
#
|
|
206
|
+
# @api private
|
|
207
|
+
# @param env [Hash{String => String}]
|
|
208
|
+
# @return [Array<String>]
|
|
209
|
+
def rbenv_shim_dirs(env)
|
|
210
|
+
root = rbenv_root(env)
|
|
211
|
+
return [] if root.nil?
|
|
212
|
+
|
|
213
|
+
dir = File.join(root, "shims")
|
|
214
|
+
File.directory?(dir) ? [dir] : []
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# rbenv install root. +RBENV_ROOT+ wins over <tt>~/.rbenv</tt>.
|
|
218
|
+
#
|
|
219
|
+
# @api private
|
|
220
|
+
# @param env [Hash{String => String}]
|
|
221
|
+
# @return [String, nil]
|
|
222
|
+
def rbenv_root(env)
|
|
223
|
+
root = env["RBENV_ROOT"]
|
|
224
|
+
return File.expand_path(root) if root && !root.empty?
|
|
225
|
+
|
|
226
|
+
home = env["HOME"]
|
|
227
|
+
return nil if home.nil? || home.empty?
|
|
228
|
+
|
|
229
|
+
File.join(home, ".rbenv")
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Existing asdf shim directories. A custom install uses +ASDF_DATA_DIR+.
|
|
233
|
+
#
|
|
234
|
+
# @api private
|
|
235
|
+
# @param env [Hash{String => String}]
|
|
236
|
+
# @return [Array<String>]
|
|
237
|
+
def asdf_shim_dirs(env)
|
|
238
|
+
dirs = []
|
|
239
|
+
data = env["ASDF_DATA_DIR"]
|
|
240
|
+
dirs << File.join(data, "shims") if data && !data.empty?
|
|
241
|
+
home = env["HOME"]
|
|
242
|
+
dirs << File.join(home, ".asdf", "shims") if home && !home.empty?
|
|
243
|
+
dirs.select { |dir| File.directory?(dir) }.uniq
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# True when `part` is a version bin under the active rbenv root.
|
|
247
|
+
#
|
|
248
|
+
# @api private
|
|
249
|
+
# @param part [String] one PATH entry.
|
|
250
|
+
# @param env [Hash{String => String}]
|
|
251
|
+
# @return [Boolean]
|
|
252
|
+
def rbenv_version_bin?(part, env)
|
|
253
|
+
root = rbenv_root(env)
|
|
254
|
+
return false if root.nil?
|
|
255
|
+
|
|
256
|
+
prefix = File.join(root, "versions")
|
|
257
|
+
File.expand_path(part).match?(%r{\A#{Regexp.escape(prefix)}/[^/]+/bin/?\z})
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# True when `part` is an asdf Ruby version bin under ~/.asdf or +ASDF_DATA_DIR+.
|
|
261
|
+
#
|
|
262
|
+
# @api private
|
|
263
|
+
# @param part [String] one PATH entry.
|
|
264
|
+
# @param env [Hash{String => String}]
|
|
265
|
+
# @return [Boolean]
|
|
266
|
+
def asdf_version_bin?(part, env)
|
|
267
|
+
return true if part.match?(%r{/\.asdf/installs/ruby/[^/]+/bin/?\z})
|
|
268
|
+
|
|
269
|
+
data = env["ASDF_DATA_DIR"]
|
|
270
|
+
return false if data.nil? || data.empty?
|
|
271
|
+
|
|
272
|
+
prefix = File.join(File.expand_path(data), "installs", "ruby")
|
|
273
|
+
File.expand_path(part).match?(%r{\A#{Regexp.escape(prefix)}/[^/]+/bin/?\z})
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# True when this daemon boots a Rails app.
|
|
277
|
+
#
|
|
278
|
+
# @api private
|
|
279
|
+
# @return [Boolean]
|
|
280
|
+
def rails_boot?
|
|
281
|
+
@boot[:rails] || @boot["rails"]
|
|
282
|
+
end
|
|
283
|
+
|
|
130
284
|
# Spawn the daemon under the app bundle and complete the ready handshake.
|
|
131
285
|
#
|
|
132
286
|
# @return [void]
|
|
133
287
|
# @raise [Mutineer::DaemonBootError] when the daemon fails to boot.
|
|
134
288
|
def spawn_daemon
|
|
135
289
|
# Plain `bundle exec ruby`, NOT `rbenv exec`, which would break CI and any
|
|
136
|
-
# non-rbenv setup.
|
|
137
|
-
#
|
|
138
|
-
# Ruby
|
|
290
|
+
# non-rbenv setup. An explicit ruby_version replaces RBENV_VERSION.
|
|
291
|
+
# With no argument, a pin already in the environment stays. That pin is
|
|
292
|
+
# often the Ruby that started the tool. `.ruby-version` applies only
|
|
293
|
+
# when no pin is set. rbenv and asdf version bins leave PATH when a shim
|
|
294
|
+
# directory exists. chruby bins stay.
|
|
139
295
|
# Everything up to the handshake is terminal, not one mutant's problem: a spawn
|
|
140
296
|
# the OS refuses (EMFILE/ENOMEM under --jobs N, ENOENT when `bundle` does not
|
|
141
297
|
# resolve) and a daemon that dies before accepting the boot payload (EPIPE on
|
|
@@ -145,7 +301,8 @@ module Mutineer
|
|
|
145
301
|
begin
|
|
146
302
|
@stdin, @stdout, @stderr, @wait_thr = Open3.popen3(
|
|
147
303
|
app_env, "bundle", "exec", "ruby",
|
|
148
|
-
"-r", DAEMON_PATH, "-e", "Mutineer::DaemonServer.run",
|
|
304
|
+
"-r", DAEMON_PATH, "-e", "Mutineer::DaemonServer.run",
|
|
305
|
+
unsetenv_others: true, chdir: @app_root
|
|
149
306
|
)
|
|
150
307
|
# Drain daemon stderr to the tool's stderr so child/boot errors are visible.
|
|
151
308
|
# Tracked (not fire-and-forget) so close_io can reclaim it on quit/respawn;
|
|
@@ -149,7 +149,8 @@ module Mutineer
|
|
|
149
149
|
cmap = CoverageMap.new(
|
|
150
150
|
source_paths: Array(@cfg["sources"]), test_paths: Array(@cfg["tests"]),
|
|
151
151
|
load_paths: Array(@cfg["load_paths"]), project_root: root,
|
|
152
|
-
boot_path: @cfg["boot"], framework: @framework, cache_dir: File.join(root, ".mutineer")
|
|
152
|
+
boot_path: @cfg["boot"], framework: @framework, cache_dir: @cfg["cache_dir"] || File.join(root, ".mutineer"),
|
|
153
|
+
capture_timeout: @cfg["capture_timeout"] || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
|
|
153
154
|
).build_via_fork(after_fork: coverage_after_fork)
|
|
154
155
|
{ "map" => cmap.map, "failed_test_files" => cmap.failed_test_files,
|
|
155
156
|
"failed_clean_tests" => cmap.failed_clean_tests }
|
|
@@ -52,11 +52,11 @@ module Mutineer
|
|
|
52
52
|
].freeze
|
|
53
53
|
|
|
54
54
|
# Turn a command template into an argv array (no shell → no eval, no
|
|
55
|
-
# injection). The
|
|
55
|
+
# injection). The <tt>%{files}</tt> token expands IN PLACE to N separate argv
|
|
56
56
|
# elements — one per path, unescaped — so a path containing a space stays a
|
|
57
57
|
# single argument. It is not a space-joined string.
|
|
58
58
|
#
|
|
59
|
-
# @param command [String] the --test-command template (contains
|
|
59
|
+
# @param command [String] the --test-command template (contains <tt>%{files}</tt>).
|
|
60
60
|
# @param files [Array<String>] test file paths to substitute.
|
|
61
61
|
# @return [Array<String>] argv.
|
|
62
62
|
def self.build_argv(command, files)
|
data/lib/mutineer/isolation.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "tempfile"
|
|
|
4
4
|
require_relative "result"
|
|
5
5
|
require_relative "parser"
|
|
6
6
|
require_relative "child_stdout"
|
|
7
|
+
require_relative "kill_channel"
|
|
7
8
|
|
|
8
9
|
module Mutineer
|
|
9
10
|
# Fork-based isolation for running one mutant. The block runs in a child
|
|
@@ -23,6 +24,9 @@ module Mutineer
|
|
|
23
24
|
class Isolation
|
|
24
25
|
DEFAULT_TIMEOUT = 10 # seconds
|
|
25
26
|
|
|
27
|
+
# Seconds between the parent's checks on a running child.
|
|
28
|
+
POLL_INTERVAL = 0.005
|
|
29
|
+
|
|
26
30
|
# Runs the block in a forked child. The block's return value (an Integer
|
|
27
31
|
# exit code) or any explicit `exit` is honoured; an unhandled exception
|
|
28
32
|
# becomes exit 2 with the cause written to STDERR.
|
|
@@ -30,18 +34,30 @@ module Mutineer
|
|
|
30
34
|
# The child silences its stdout (see {ChildStdout.silence}) before the
|
|
31
35
|
# block runs, so test output never reaches the user. Stderr stays open.
|
|
32
36
|
#
|
|
37
|
+
# With `channel: true` (a `--matrix` run) the block gets the write end of a
|
|
38
|
+
# pipe for {KillChannel} lines. The parent reads it while it waits, so a
|
|
39
|
+
# child with more than a pipe buffer to say never blocks, and attaches the
|
|
40
|
+
# lines to the Result as {Kills}. The verdict is the one a run without
|
|
41
|
+
# `--matrix` gives (see {.finish}).
|
|
42
|
+
#
|
|
33
43
|
# @param timeout [Integer] timeout in seconds.
|
|
44
|
+
# @param channel [Boolean] open a {KillChannel} pipe for the block.
|
|
45
|
+
# @yieldparam channel [IO, nil] the pipe's write end, or nil without `channel`.
|
|
34
46
|
# @yieldreturn [Integer] child exit status.
|
|
35
47
|
# @return [Mutineer::Result] result from the child process.
|
|
36
|
-
def self.run(timeout: DEFAULT_TIMEOUT)
|
|
48
|
+
def self.run(timeout: DEFAULT_TIMEOUT, channel: false)
|
|
49
|
+
rd, wr = IO.pipe if channel
|
|
50
|
+
wr&.sync = true
|
|
37
51
|
pid = fork do
|
|
52
|
+
rd&.close
|
|
38
53
|
# Own process group so a timeout kill can reap grandchildren (match
|
|
39
54
|
# daemon/external backends). Best-effort: if setpgid fails, kill the pid.
|
|
40
55
|
Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
|
|
41
56
|
code = 0
|
|
42
57
|
begin
|
|
43
58
|
ChildStdout.silence
|
|
44
|
-
|
|
59
|
+
# A block that takes no channel (a zero-arity lambda) still runs.
|
|
60
|
+
result = channel ? yield(wr) : yield
|
|
45
61
|
code = result.is_a?(Integer) ? result : 0
|
|
46
62
|
rescue SystemExit => e
|
|
47
63
|
code = e.status
|
|
@@ -57,13 +73,17 @@ module Mutineer
|
|
|
57
73
|
exit!(code)
|
|
58
74
|
end
|
|
59
75
|
|
|
76
|
+
wr&.close
|
|
77
|
+
buffer = +"" if rd
|
|
78
|
+
reading = !rd.nil?
|
|
79
|
+
|
|
60
80
|
# Single-threaded deadline poll: we are the ONLY caller of waitpid on this
|
|
61
81
|
# pid, so we never reap-then-kill. SIGKILL the process group only after
|
|
62
82
|
# WNOHANG shows the child is still alive past the deadline.
|
|
63
83
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
64
84
|
loop do
|
|
65
85
|
reaped, status = Process.waitpid2(pid, Process::WNOHANG)
|
|
66
|
-
return decode(status) if reaped
|
|
86
|
+
return finish(decode(status), rd, buffer, finished: true) if reaped
|
|
67
87
|
|
|
68
88
|
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
69
89
|
begin
|
|
@@ -74,14 +94,79 @@ module Mutineer
|
|
|
74
94
|
begin
|
|
75
95
|
_reaped, status = Process.waitpid2(pid)
|
|
76
96
|
# Child may have finished cleanly between WNOHANG and kill; honor it.
|
|
77
|
-
return decode(status) if status && status.exited? && !status.signaled?
|
|
97
|
+
return finish(decode(status), rd, buffer, finished: true) if status && status.exited? && !status.signaled?
|
|
78
98
|
rescue Errno::ECHILD
|
|
79
99
|
# already reaped
|
|
80
100
|
end
|
|
81
|
-
return Result.timeout
|
|
101
|
+
return finish(Result.timeout, rd, buffer, finished: false)
|
|
102
|
+
end
|
|
103
|
+
if reading
|
|
104
|
+
IO.select([rd], nil, nil, POLL_INTERVAL)
|
|
105
|
+
reading = drain(rd, buffer)
|
|
106
|
+
else
|
|
107
|
+
sleep POLL_INTERVAL
|
|
82
108
|
end
|
|
83
|
-
sleep 0.005
|
|
84
109
|
end
|
|
110
|
+
ensure
|
|
111
|
+
rd&.close
|
|
112
|
+
# Closed after the fork in the normal path; still open if fork raised.
|
|
113
|
+
wr.close if wr && !wr.closed?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Reads what the channel holds now, without blocking.
|
|
117
|
+
#
|
|
118
|
+
# @api private
|
|
119
|
+
# @param rd [IO] the channel's read end.
|
|
120
|
+
# @param buffer [String] bytes read so far; appended to.
|
|
121
|
+
# @return [Boolean] false once the channel is at end of file.
|
|
122
|
+
def self.drain(rd, buffer)
|
|
123
|
+
loop do
|
|
124
|
+
chunk = rd.read_nonblock(65_536, exception: false)
|
|
125
|
+
return true if chunk == :wait_readable
|
|
126
|
+
return false if chunk.nil?
|
|
127
|
+
|
|
128
|
+
buffer << chunk
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The Result to return. Without a channel it is `result` itself. With one,
|
|
133
|
+
# the rest of the channel is read, and the Result carries the {Kills} it
|
|
134
|
+
# names.
|
|
135
|
+
#
|
|
136
|
+
# The verdict matches a run without `--matrix`, which skips everything after
|
|
137
|
+
# the first failing serial test except the code around it (see
|
|
138
|
+
# {KillChannel}). So when a serial test has named a kill and the child then
|
|
139
|
+
# ended inside a region that run would skip (an exit, a crash or the
|
|
140
|
+
# timeout in a later test, class or group), the mutant is `killed`: the
|
|
141
|
+
# plain run never reached that point. An end anywhere else keeps the exit
|
|
142
|
+
# status, because the plain run reaches that code too: the rest of the
|
|
143
|
+
# failing test's class wrapper or group hooks, or the suite hooks. A kill in
|
|
144
|
+
# the parallel tests of a Minitest run also keeps it, because the stop
|
|
145
|
+
# cannot skip tests already queued.
|
|
146
|
+
#
|
|
147
|
+
# The row is complete only when the child ended before the timeout, sent a
|
|
148
|
+
# valid stream with both `start` and `end`, lost no line, and its kills
|
|
149
|
+
# agree with the verdict (a killed mutant names a killer; a survivor names
|
|
150
|
+
# none). An invalid stream promotes nothing, and its tests are dropped: an
|
|
151
|
+
# out-of-order line means the parent cannot tell which outcomes are real.
|
|
152
|
+
#
|
|
153
|
+
# @api private
|
|
154
|
+
# @param result [Mutineer::Result] the verdict from the exit status or the timeout.
|
|
155
|
+
# @param rd [IO, nil] the channel's read end.
|
|
156
|
+
# @param buffer [String, nil] bytes read so far.
|
|
157
|
+
# @param finished [Boolean] the child ended before the timeout.
|
|
158
|
+
# @return [Mutineer::Result]
|
|
159
|
+
def self.finish(result, rd, buffer, finished:)
|
|
160
|
+
return result unless rd
|
|
161
|
+
|
|
162
|
+
drain(rd, buffer)
|
|
163
|
+
report = KillChannel.parse(buffer)
|
|
164
|
+
return result.with(kills: Kills.new(killed_by: [], ran: [], complete: false)) if report.invalid
|
|
165
|
+
|
|
166
|
+
result = Result.killed if report.started && report.serial_kill && report.skipping.positive?
|
|
167
|
+
agrees = result.killed? ? report.killed.any? : result.survived? && report.killed.empty?
|
|
168
|
+
complete = finished && report.started && report.finished && report.lost.zero? && agrees
|
|
169
|
+
result.with(kills: Kills.new(killed_by: report.killed, ran: report.ran, complete: complete))
|
|
85
170
|
end
|
|
86
171
|
|
|
87
172
|
# Strategy 7a (default): write the whole mutated file and `load` it, which
|