mutineer-mongoid 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +1194 -0
- data/LICENSE +21 -0
- data/README.md +659 -0
- data/bin/mutineer +6 -0
- data/lib/mutineer/baseline.rb +185 -0
- data/lib/mutineer/changed_lines.rb +136 -0
- data/lib/mutineer/child_stdout.rb +35 -0
- data/lib/mutineer/cli.rb +681 -0
- data/lib/mutineer/config.rb +372 -0
- data/lib/mutineer/coverage_map.rb +1366 -0
- data/lib/mutineer/daemon_backend.rb +351 -0
- data/lib/mutineer/daemon_client.rb +407 -0
- data/lib/mutineer/daemon_server.rb +351 -0
- data/lib/mutineer/external_backend.rb +288 -0
- data/lib/mutineer/file_swap.rb +194 -0
- data/lib/mutineer/isolation.rb +321 -0
- data/lib/mutineer/job_plan.rb +352 -0
- 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 +145 -0
- data/lib/mutineer/minitest_integration.rb +137 -0
- data/lib/mutineer/mongoid_failures.rb +82 -0
- data/lib/mutineer/mongoid_worker_db.rb +97 -0
- data/lib/mutineer/mutant_id.rb +126 -0
- data/lib/mutineer/mutation.rb +27 -0
- data/lib/mutineer/mutator_registry.rb +109 -0
- data/lib/mutineer/mutators/arithmetic.rb +35 -0
- data/lib/mutineer/mutators/array_literal.rb +44 -0
- data/lib/mutineer/mutators/base.rb +105 -0
- data/lib/mutineer/mutators/boolean_connector.rb +52 -0
- data/lib/mutineer/mutators/boolean_literal.rb +56 -0
- data/lib/mutineer/mutators/chain_link.rb +132 -0
- data/lib/mutineer/mutators/collection_method.rb +44 -0
- data/lib/mutineer/mutators/comparison.rb +35 -0
- data/lib/mutineer/mutators/condition_forcing.rb +134 -0
- data/lib/mutineer/mutators/condition_negation.rb +49 -0
- data/lib/mutineer/mutators/literal_mutation.rb +51 -0
- data/lib/mutineer/mutators/negation_removal.rb +44 -0
- data/lib/mutineer/mutators/operand_removal.rb +58 -0
- data/lib/mutineer/mutators/operator_assignment.rb +113 -0
- data/lib/mutineer/mutators/range_literal.rb +54 -0
- data/lib/mutineer/mutators/regex_literal.rb +72 -0
- data/lib/mutineer/mutators/return_nil.rb +72 -0
- data/lib/mutineer/mutators/safe_navigation.rb +76 -0
- data/lib/mutineer/mutators/statement_removal.rb +33 -0
- data/lib/mutineer/mutators/string_literal.rb +34 -0
- data/lib/mutineer/orphan_guard.rb +28 -0
- data/lib/mutineer/pairing.rb +298 -0
- data/lib/mutineer/parser.rb +45 -0
- data/lib/mutineer/progress.rb +46 -0
- data/lib/mutineer/project.rb +506 -0
- data/lib/mutineer/project_path.rb +57 -0
- data/lib/mutineer/rails_worker_db.rb +212 -0
- data/lib/mutineer/reporter.rb +926 -0
- data/lib/mutineer/result.rb +240 -0
- data/lib/mutineer/runner.rb +418 -0
- data/lib/mutineer/statement_lines.rb +183 -0
- data/lib/mutineer/subject.rb +38 -0
- data/lib/mutineer/test_runners/minitest.rb +22 -0
- data/lib/mutineer/test_runners/rspec.rb +330 -0
- data/lib/mutineer/test_runners.rb +30 -0
- data/lib/mutineer/version.rb +6 -0
- data/lib/mutineer/worker_pool.rb +147 -0
- data/lib/mutineer.rb +48 -0
- metadata +143 -0
|
@@ -0,0 +1,1366 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
require "coverage"
|
|
8
|
+
require "set"
|
|
9
|
+
require_relative "minitest_integration"
|
|
10
|
+
require_relative "test_runners"
|
|
11
|
+
require_relative "child_stdout"
|
|
12
|
+
require_relative "orphan_guard"
|
|
13
|
+
require_relative "project_path"
|
|
14
|
+
require_relative "pairing"
|
|
15
|
+
|
|
16
|
+
module Mutineer
|
|
17
|
+
# Maps `(source_file, line) -> [test_files]` so each mutant runs only against
|
|
18
|
+
# the tests that actually exercise its line. Built once, then queried per
|
|
19
|
+
# mutant via #tests_for. Persisted to .mutineer/coverage.json with a
|
|
20
|
+
# content-based digest that rebuilds the map whenever any tracked file changes.
|
|
21
|
+
#
|
|
22
|
+
# Keys are "file:line" strings (relative to project_root) everywhere, in
|
|
23
|
+
# memory and on disk, so load/save needs no key transformation.
|
|
24
|
+
class CoverageMap
|
|
25
|
+
# Seconds per coverage subprocess before the parent kills it.
|
|
26
|
+
DEFAULT_CAPTURE_TIMEOUT = 120
|
|
27
|
+
|
|
28
|
+
# File descriptor in a capture subprocess that carries the JSON result to
|
|
29
|
+
# the parent. Stdout stays free for test output, which goes to File::NULL.
|
|
30
|
+
RESULT_FD = 3
|
|
31
|
+
|
|
32
|
+
# Seconds a capture waits for its result after the child exits in time.
|
|
33
|
+
# The result is already in the pipe then; the wait only lets the reader
|
|
34
|
+
# thread run, even when the capture deadline has passed (#129).
|
|
35
|
+
RESULT_GRACE = 1
|
|
36
|
+
|
|
37
|
+
attr_reader :project_root, :failed_test_files, :failed_clean_tests, :phase_a_ran, :map
|
|
38
|
+
|
|
39
|
+
# The source lines that ran while the app booted or the sources and the
|
|
40
|
+
# `--require` files loaded, before any test ran, as a Set of "file:line"
|
|
41
|
+
# keys like {#map}'s (#187, #217).
|
|
42
|
+
# A mutant on such a line can change a value computed before the mutant was
|
|
43
|
+
# applied, so its verdict is not trusted (`ran_at_load`).
|
|
44
|
+
#
|
|
45
|
+
# @return [Set<String>]
|
|
46
|
+
attr_reader :load_lines
|
|
47
|
+
|
|
48
|
+
# The source methods that were called while the app booted or the sources
|
|
49
|
+
# and the `--require` files loaded, as a Set of "file:line:column" keys of
|
|
50
|
+
# their `def` (#209). Line coverage cannot tell this for a one-line or
|
|
51
|
+
# endless method: its `def` line counts when the method is defined.
|
|
52
|
+
#
|
|
53
|
+
# @return [Set<String>]
|
|
54
|
+
attr_reader :load_methods
|
|
55
|
+
|
|
56
|
+
# Seconds each test file took to capture, keyed by project-relative path
|
|
57
|
+
# (#203). {#order_tests} runs the cheaper files first.
|
|
58
|
+
#
|
|
59
|
+
# @return [Hash{String => Float}]
|
|
60
|
+
attr_reader :timings
|
|
61
|
+
|
|
62
|
+
# Build a QUERY-ONLY map from data captured elsewhere (the daemon builds the
|
|
63
|
+
# map app-side and ships `map` + `failed_test_files` over IPC; the tool
|
|
64
|
+
# reconstructs it here for per-mutant selection). Skips the capture machinery
|
|
65
|
+
# entirely: only the three fields #tests_for / #method_uncapturable? read are
|
|
66
|
+
# set.
|
|
67
|
+
#
|
|
68
|
+
# @param map [Hash] the "file:line" => [test_files] map.
|
|
69
|
+
# @param failed_test_files [Array<String>] test files whose capture failed.
|
|
70
|
+
# @param project_root [String] project root (for path relativization).
|
|
71
|
+
# @param failed_clean_tests [Array<String>] test files whose unmutated run failed.
|
|
72
|
+
# @param load_lines [Array<String>, Set<String>] "file:line" keys that ran at load ({#load_lines}).
|
|
73
|
+
# @param load_methods [Array<String>, Set<String>] "file:line:column" keys of methods called at load
|
|
74
|
+
# ({#load_methods}).
|
|
75
|
+
# @param timings [Hash{String => Float}] capture seconds per test file ({#timings}).
|
|
76
|
+
# @return [Mutineer::CoverageMap] a query-only map.
|
|
77
|
+
def self.from_data(map:, failed_test_files:, project_root:, failed_clean_tests: [], load_lines: [],
|
|
78
|
+
load_methods: [], timings: {})
|
|
79
|
+
instance = allocate
|
|
80
|
+
instance.instance_variable_set(:@map, map || {})
|
|
81
|
+
instance.instance_variable_set(:@timings, timings || {})
|
|
82
|
+
instance.instance_variable_set(:@load_lines, Set.new(load_lines || []))
|
|
83
|
+
instance.instance_variable_set(:@load_methods, Set.new(load_methods || []))
|
|
84
|
+
instance.instance_variable_set(:@failed_test_files, failed_test_files || [])
|
|
85
|
+
instance.instance_variable_set(:@failed_clean_tests, failed_clean_tests || [])
|
|
86
|
+
instance.instance_variable_set(:@project_root, project_root)
|
|
87
|
+
instance
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def initialize(source_paths:, test_paths:, cache_dir: ".mutineer",
|
|
91
|
+
load_paths: ["lib"], project_root: Dir.pwd,
|
|
92
|
+
capture_timeout: DEFAULT_CAPTURE_TIMEOUT, boot_path: nil,
|
|
93
|
+
framework: "minitest", verbose: false, require_paths: [])
|
|
94
|
+
@source_paths = Array(source_paths)
|
|
95
|
+
@require_paths = Array(require_paths)
|
|
96
|
+
@test_paths = Array(test_paths)
|
|
97
|
+
@cache_dir = cache_dir
|
|
98
|
+
@load_paths = Array(load_paths)
|
|
99
|
+
@project_root = project_root
|
|
100
|
+
@capture_timeout = capture_timeout
|
|
101
|
+
@boot_path = boot_path
|
|
102
|
+
@framework = framework || "minitest"
|
|
103
|
+
@verbose = verbose
|
|
104
|
+
@map = {}
|
|
105
|
+
@failed_test_files = []
|
|
106
|
+
@failed_clean_tests = []
|
|
107
|
+
@load_lines = Set.new
|
|
108
|
+
@load_methods = Set.new
|
|
109
|
+
@timings = {}
|
|
110
|
+
@loaded_dependencies = {}
|
|
111
|
+
@phase_a_ran = false
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Standalone entry: load the cached map when the content digest matches,
|
|
115
|
+
# otherwise rebuild from subprocesses and overwrite the cache.
|
|
116
|
+
def build_or_load
|
|
117
|
+
warn_external_sources
|
|
118
|
+
cached_or { run_phase_a }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Boot-mode build: Coverage is already running in the parent (started before
|
|
122
|
+
# the app booted, so booted source lines are instrumented). A clean `ruby`
|
|
123
|
+
# subprocess has no booted env, so per-test coverage is captured by FORKING
|
|
124
|
+
# the booted parent instead. Inverts into the same map #tests_for reads, and
|
|
125
|
+
# reuses the digest cache (the digest mixes in the boot file so a boot cache
|
|
126
|
+
# never collides with a standalone one).
|
|
127
|
+
#
|
|
128
|
+
# The lines that ran during boot are read here on every run, cache or not:
|
|
129
|
+
# the boot runs every time, and no test is credited with those lines.
|
|
130
|
+
# @param after_fork [Proc, nil] child-side database routing.
|
|
131
|
+
# @param before_fork [Proc, nil] parent-side socket disconnection.
|
|
132
|
+
# @param cleanup [Proc, nil] parent-side cleanup, passed the reaped child pid.
|
|
133
|
+
# @param cache_key [String, nil] additional state affecting coverage (MongoDB snapshot).
|
|
134
|
+
def build_via_fork(after_fork: nil, before_fork: nil, cleanup: nil, cache_key: nil)
|
|
135
|
+
@cache_key = cache_key
|
|
136
|
+
@before_fork = before_fork
|
|
137
|
+
@fork_cleanup = cleanup
|
|
138
|
+
warn_external_sources
|
|
139
|
+
# Matched by real path in #record_load: a Coverage key is the path as
|
|
140
|
+
# required, which can differ from the configured one (/var, /private/var).
|
|
141
|
+
booted = Coverage.running? ? Coverage.peek_result : {}
|
|
142
|
+
record_load(booted.transform_values { |v| v.is_a?(Hash) ? load_entry(v[:lines], v[:methods]) : v })
|
|
143
|
+
cached_or(after_fork: after_fork) { run_phase_a_via_fork(after_fork: after_fork) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Lookup: the test files that cover `file:line`, or [] when none do.
|
|
147
|
+
# Per-file granularity; upgrade to per-method when throughput warrants
|
|
148
|
+
# (requires Minitest method isolation + finer Coverage tracking).
|
|
149
|
+
def tests_for(file, line)
|
|
150
|
+
@map["#{relativize(file)}:#{line}"] || []
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The covering `tests` of a mutant in `file`, in the order a mutant run
|
|
154
|
+
# loads them (#203): the files {Pairing.infer_tests} pairs with `file`
|
|
155
|
+
# first, then the cheapest by {#timings}. A file with no timing comes
|
|
156
|
+
# after the timed ones, and the path breaks a tie, so the same cache gives
|
|
157
|
+
# the same order on every run. Costs compare in doubling buckets
|
|
158
|
+
# (`log2(seconds - fastest + 1)`), so files of close cost keep their path
|
|
159
|
+
# order when a rebuild measures them again. Taking off the fastest
|
|
160
|
+
# captured file's time removes the startup cost every standalone capture
|
|
161
|
+
# pays; a failed capture is timed again on each run, so it does not count.
|
|
162
|
+
# A run that stops at the first failure then reaches a fast killing test
|
|
163
|
+
# before a slow file uses up the timeout.
|
|
164
|
+
#
|
|
165
|
+
# @param file [String] the mutated source file path.
|
|
166
|
+
# @param tests [Array<String>] project-relative test paths from {#tests_for}.
|
|
167
|
+
# @return [Array<String>] the same paths, reordered.
|
|
168
|
+
def order_tests(file, tests)
|
|
169
|
+
rel = relativize(absolute(file))
|
|
170
|
+
@paired ||= {}
|
|
171
|
+
paired = @paired[rel] ||= Pairing.infer_tests(rel, project_root: @project_root,
|
|
172
|
+
prefer: @framework || "minitest")
|
|
173
|
+
base = @timings.except(*@failed_test_files).values.min || 0
|
|
174
|
+
tests.sort_by do |t|
|
|
175
|
+
cost = @timings[t]
|
|
176
|
+
[paired.include?(t) ? 0 : 1, cost ? Math.log2(cost - base + 1).floor : Float::INFINITY, t]
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# True when `file:line` ran while the app booted or the sources loaded
|
|
181
|
+
# (see {#load_lines}).
|
|
182
|
+
#
|
|
183
|
+
# @param file [String] source file path.
|
|
184
|
+
# @param line [Integer] 1-based line.
|
|
185
|
+
# @return [Boolean]
|
|
186
|
+
def ran_at_load?(file, line)
|
|
187
|
+
@load_lines.include?("#{relativize(file)}:#{line}")
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# True when the method whose `def` starts at `line` and `column` of `file`
|
|
191
|
+
# was called while the app booted or the sources loaded (see {#load_methods}).
|
|
192
|
+
#
|
|
193
|
+
# @param file [String] source file path.
|
|
194
|
+
# @param line [Integer] 1-based line of the `def`.
|
|
195
|
+
# @param column [Integer] 0-based byte column of the `def`.
|
|
196
|
+
# @return [Boolean]
|
|
197
|
+
def method_ran_at_load?(file, line, column)
|
|
198
|
+
@load_methods.include?("#{relativize(file)}:#{line}:#{column}")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Is this source file's empty coverage the result of an *errored* capture
|
|
202
|
+
# rather than a genuine coverage gap? True iff some capture failed this run
|
|
203
|
+
# AND this file got zero coverage from any successful capture AND a failed
|
|
204
|
+
# test file maps to it by the _test/_spec/test_ naming convention. Derived
|
|
205
|
+
# from already-persisted state (@map keys + @failed_test_files). A split
|
|
206
|
+
# name checks the longer source files that existed when that check first
|
|
207
|
+
# ran for this map. Adding or deleting one of those files changes the
|
|
208
|
+
# coverage digest, so the next run captures again instead of retargeting
|
|
209
|
+
# a stored failure.
|
|
210
|
+
#
|
|
211
|
+
# File-level, convention-based attribution. A line covered only by a failed
|
|
212
|
+
# test in an otherwise-covered file stays no_coverage (condition 2), and a
|
|
213
|
+
# source with no naming-convention test match is never tainted. Upgrade path:
|
|
214
|
+
# persist per-file coverage per successful run and diff against the failed
|
|
215
|
+
# set, or record test->source targets explicitly.
|
|
216
|
+
def uncapturable_source?(file)
|
|
217
|
+
return false if @failed_test_files.empty?
|
|
218
|
+
|
|
219
|
+
rel = relativize(absolute(file))
|
|
220
|
+
return false if covered_source_files.include?(rel)
|
|
221
|
+
|
|
222
|
+
failed_test_blames?(rel)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Per-method taint. A mutant on a line whose enclosing method got zero
|
|
226
|
+
# successful coverage, in a file a failed sibling test targets, is
|
|
227
|
+
# :uncapturable (the capture that would have covered it errored), NOT a
|
|
228
|
+
# genuine gap. A method with any covered line means its uncovered lines are a
|
|
229
|
+
# real :no_coverage. A failed capture emits no coverage, so per-line intent is
|
|
230
|
+
# unknowable; method-range + successful coverage is the finest derivable
|
|
231
|
+
# signal. Fully-failed files behave exactly as uncapturable_source? did
|
|
232
|
+
# (every method range has zero coverage).
|
|
233
|
+
#
|
|
234
|
+
# @param file [String] source file path.
|
|
235
|
+
# @param line_range [Range] 1-based enclosing-method line range.
|
|
236
|
+
# @return [Boolean]
|
|
237
|
+
def method_uncapturable?(file, line_range)
|
|
238
|
+
return false if @failed_test_files.empty?
|
|
239
|
+
|
|
240
|
+
rel = relativize(absolute(file))
|
|
241
|
+
return false unless failed_test_blames?(rel)
|
|
242
|
+
|
|
243
|
+
line_range.none? { |ln| @map.key?("#{rel}:#{ln}") }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
private
|
|
247
|
+
|
|
248
|
+
# Source rel-paths that received coverage from any successful capture.
|
|
249
|
+
def covered_source_files
|
|
250
|
+
@map.keys.map { |k| k.rpartition(":").first }.to_set
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# True when a failed test file pairs with `source_rel` by convention.
|
|
254
|
+
# Exact `_test` / `_spec` / `test_` names match by basename, as before.
|
|
255
|
+
# A split `<name>_*_test.rb` matches only in the mirrored test directory,
|
|
256
|
+
# and only when no longer source file owns that name (#87). The answer
|
|
257
|
+
# is kept for this map, so a later delete does not move the failure.
|
|
258
|
+
#
|
|
259
|
+
# @param source_rel [String] project-relative source path.
|
|
260
|
+
# @return [Boolean]
|
|
261
|
+
def failed_test_blames?(source_rel)
|
|
262
|
+
@blame_for ||= {}
|
|
263
|
+
return @blame_for[source_rel] if @blame_for.key?(source_rel)
|
|
264
|
+
|
|
265
|
+
@blame_for[source_rel] = exact_failed_test?(source_rel) || split_failed_test?(source_rel)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# True when a failed exact `_test` / `_spec` / `test_` file pairs with
|
|
269
|
+
# this source. An `app/` or `lib/` source only accepts a `test/` or
|
|
270
|
+
# `spec/` file from its mirrored directory, so
|
|
271
|
+
# `test/foo/bar_upsert_test.rb` does not taint `app/other/bar_upsert.rb`.
|
|
272
|
+
# Any other source still matches by basename. A fixture under
|
|
273
|
+
# `test/fixtures/` and an explicit test both name their source that way.
|
|
274
|
+
#
|
|
275
|
+
# @param source_rel [String] project-relative source path.
|
|
276
|
+
# @return [Boolean]
|
|
277
|
+
def exact_failed_test?(source_rel)
|
|
278
|
+
name = File.basename(source_rel, ".rb")
|
|
279
|
+
base, lib = Pairing.logical_path(source_rel)
|
|
280
|
+
@failed_test_files.any? do |test_path|
|
|
281
|
+
next false unless failed_test_target_name(test_path) == name
|
|
282
|
+
|
|
283
|
+
rel = relativize(test_path)
|
|
284
|
+
dir = File.dirname(rel)
|
|
285
|
+
if app_or_lib_source?(source_rel) && conventional_test_dir?(dir)
|
|
286
|
+
mirrored_test_dir?(rel, base, lib) || spec_mirror?(rel, base, lib)
|
|
287
|
+
else
|
|
288
|
+
true
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# True when pairing gives this source a mirrored test directory.
|
|
294
|
+
#
|
|
295
|
+
# @param source_rel [String] project-relative source path.
|
|
296
|
+
# @return [Boolean]
|
|
297
|
+
def app_or_lib_source?(source_rel)
|
|
298
|
+
source_rel.start_with?("app/", "lib/")
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Basename a failed test file would pair with, before the directory check.
|
|
302
|
+
#
|
|
303
|
+
# @param test_path [String]
|
|
304
|
+
# @return [String]
|
|
305
|
+
def failed_test_target_name(test_path)
|
|
306
|
+
name = File.basename(test_path, ".rb")
|
|
307
|
+
case name
|
|
308
|
+
when /_(test|spec)\z/ then name.sub(/_(test|spec)\z/, "")
|
|
309
|
+
when "test_helper" then name
|
|
310
|
+
else name.delete_prefix("test_")
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# True when `dir` is `test`, `spec`, or a directory under one of them.
|
|
315
|
+
#
|
|
316
|
+
# @param dir [String] project-relative directory.
|
|
317
|
+
# @return [Boolean]
|
|
318
|
+
def conventional_test_dir?(dir)
|
|
319
|
+
dir == "test" || dir == "spec" || dir.start_with?("test/", "spec/")
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# True when a failed split test file pairs with this source. Directory and
|
|
323
|
+
# the longer-source check match {Pairing}, so a test outside the mirror, or
|
|
324
|
+
# one owned by `user_session.rb`, does not taint `user.rb`.
|
|
325
|
+
#
|
|
326
|
+
# @param source_rel [String] project-relative source path.
|
|
327
|
+
# @return [Boolean]
|
|
328
|
+
def split_failed_test?(source_rel)
|
|
329
|
+
base, lib = Pairing.logical_path(source_rel)
|
|
330
|
+
name = File.basename(base)
|
|
331
|
+
@failed_test_files.any? do |t|
|
|
332
|
+
test_rel = relativize(t)
|
|
333
|
+
entry = File.basename(test_rel)
|
|
334
|
+
next false unless Pairing.split_entry?(entry, name)
|
|
335
|
+
next false unless mirrored_test_dir?(test_rel, base, lib)
|
|
336
|
+
next false if Pairing.claimed_by_longer_source?(@project_root, base, entry, File.dirname(test_rel))
|
|
337
|
+
|
|
338
|
+
true
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# True when `test_rel` sits in a mirrored test directory for `base`.
|
|
343
|
+
#
|
|
344
|
+
# @param test_rel [String] project-relative test path.
|
|
345
|
+
# @param base [String] logical source path without extension.
|
|
346
|
+
# @param lib [Boolean] whether the source originated from lib/.
|
|
347
|
+
# @return [Boolean]
|
|
348
|
+
def mirrored_test_dir?(test_rel, base, lib)
|
|
349
|
+
dirs = [Pairing.mirror_dir("test", base)]
|
|
350
|
+
dirs << Pairing.mirror_dir("test/lib", base) if lib
|
|
351
|
+
dirs.include?(File.dirname(test_rel))
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# True when `test_rel` sits in a mirrored spec directory for `base`.
|
|
355
|
+
# Split Minitest names stay in {#mirrored_test_dir?}. An exact `_spec`
|
|
356
|
+
# file uses this directory.
|
|
357
|
+
#
|
|
358
|
+
# @param test_rel [String] project-relative test path.
|
|
359
|
+
# @param base [String] logical source path without extension.
|
|
360
|
+
# @param lib [Boolean] whether the source originated from lib/.
|
|
361
|
+
# @return [Boolean]
|
|
362
|
+
def spec_mirror?(test_rel, base, lib)
|
|
363
|
+
dirs = [Pairing.mirror_dir("spec", base)]
|
|
364
|
+
dirs << Pairing.mirror_dir("spec/lib", base) if lib
|
|
365
|
+
dirs.include?(File.dirname(test_rel))
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Shared cache dance for both build paths: hit the digest-keyed cache, else
|
|
369
|
+
# yield to populate @map and persist it. A digest match is not proof that
|
|
370
|
+
# today's unmutated suite still passes — re-check on a cache hit.
|
|
371
|
+
#
|
|
372
|
+
# @param after_fork [Proc, nil] boot-mode fork hook forwarded to a clean re-check.
|
|
373
|
+
# @yield when the cache is missing or stale.
|
|
374
|
+
# @return [Mutineer::CoverageMap] self.
|
|
375
|
+
def cached_or(after_fork: nil)
|
|
376
|
+
@digest = compute_digest
|
|
377
|
+
@digest = Digest::SHA256.hexdigest("#{@digest}\0#{@cache_key}") if @cache_key
|
|
378
|
+
cached = read_cache
|
|
379
|
+
# A cache from before load methods (#209) or test timings (#203) were
|
|
380
|
+
# saved rebuilds once. Boot mode reads its load lines and methods from
|
|
381
|
+
# the live boot instead (#build_via_fork), but its map from before #209
|
|
382
|
+
# lacks the `def` lines of the methods each test called.
|
|
383
|
+
if cached && cached["digest"] == @digest && dependencies_match?(cached) &&
|
|
384
|
+
cached.key?("load_methods") && cached.key?("timings")
|
|
385
|
+
@map = cached["map"] || {}
|
|
386
|
+
@timings = cached["timings"] || {}
|
|
387
|
+
unless @boot_path
|
|
388
|
+
@load_lines = Set.new(cached["load_lines"])
|
|
389
|
+
@load_methods = Set.new(cached["load_methods"])
|
|
390
|
+
end
|
|
391
|
+
@failed_test_files = cached["failed_test_files"] || []
|
|
392
|
+
@failed_clean_tests = []
|
|
393
|
+
@loaded_dependencies = cached["dependencies"] || {}
|
|
394
|
+
retry_failed_captures(after_fork)
|
|
395
|
+
warn_incomplete unless @failed_test_files.empty?
|
|
396
|
+
verify_cached_clean(after_fork: after_fork)
|
|
397
|
+
verify_combined_clean(after_fork: after_fork)
|
|
398
|
+
save
|
|
399
|
+
return self
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
yield
|
|
403
|
+
verify_combined_clean(after_fork: after_fork)
|
|
404
|
+
save
|
|
405
|
+
self
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Runs standalone coverage capture.
|
|
409
|
+
#
|
|
410
|
+
# @api private
|
|
411
|
+
def run_phase_a
|
|
412
|
+
@phase_a_ran = true
|
|
413
|
+
@map = {}
|
|
414
|
+
@load_lines = Set.new
|
|
415
|
+
@load_methods = Set.new
|
|
416
|
+
@timings = {}
|
|
417
|
+
@failed_test_files = []
|
|
418
|
+
@failed_clean_tests = []
|
|
419
|
+
@loaded_dependencies = {}
|
|
420
|
+
|
|
421
|
+
@test_paths.each do |test_path|
|
|
422
|
+
payload = timed(test_path) { capture(test_path) }
|
|
423
|
+
next unless payload
|
|
424
|
+
|
|
425
|
+
accept_capture_payload(test_path, payload)
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Boot-mode capture. For each test file, fork the booted parent; the child
|
|
430
|
+
# resets its Coverage delta, runs that ONE test, and marshals back the raw
|
|
431
|
+
# per-source coverage counts. record() inverts them exactly as the subprocess
|
|
432
|
+
# path does. Serial fork (one test at a time): boot apps fork cheaply via COW
|
|
433
|
+
# and per-test isolation matters more than throughput here.
|
|
434
|
+
def run_phase_a_via_fork(after_fork:)
|
|
435
|
+
@phase_a_ran = true
|
|
436
|
+
@map = {}
|
|
437
|
+
@timings = {}
|
|
438
|
+
@failed_test_files = []
|
|
439
|
+
@failed_clean_tests = []
|
|
440
|
+
@loaded_dependencies = {}
|
|
441
|
+
abs_sources = abs_source_paths
|
|
442
|
+
|
|
443
|
+
@test_paths.each do |test_path|
|
|
444
|
+
accept_fork_payload(test_path, timed(test_path) { fork_capture(absolute(test_path), abs_sources, after_fork) })
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# Runs the block, a capture of `test_path`, and records its wall-clock
|
|
449
|
+
# seconds in {#timings}. The parent times the whole capture, so the
|
|
450
|
+
# numbers compare files, not exact test cost.
|
|
451
|
+
#
|
|
452
|
+
# @api private
|
|
453
|
+
# @param test_path [String] test file path.
|
|
454
|
+
# @yieldreturn [Object] the capture result.
|
|
455
|
+
# @return [Object] the block's value.
|
|
456
|
+
def timed(test_path)
|
|
457
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
458
|
+
value = yield
|
|
459
|
+
@timings[relativize(test_path)] = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(3)
|
|
460
|
+
value
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Records a {#fork_capture} result. Hash = capture result, String = error
|
|
464
|
+
# diagnostic from the child, :timeout = the capture deadline passed, nil =
|
|
465
|
+
# pipe gone / empty. The String diagnostic is what becomes an
|
|
466
|
+
# :uncapturable status.
|
|
467
|
+
#
|
|
468
|
+
# @api private
|
|
469
|
+
# @param test_path [String] test file path.
|
|
470
|
+
# @param payload [Hash, String, Symbol, nil] what {#fork_capture} returned.
|
|
471
|
+
# @return [void]
|
|
472
|
+
def accept_fork_payload(test_path, payload)
|
|
473
|
+
case payload
|
|
474
|
+
when Hash then accept_capture_payload(test_path, payload)
|
|
475
|
+
when :timeout then fail_test(test_path, "timed out after #{@capture_timeout}s")
|
|
476
|
+
when String
|
|
477
|
+
fail_test(test_path, @verbose ? "fork capture failed: #{payload}" :
|
|
478
|
+
"fork capture produced no result (re-run with --verbose for the error)")
|
|
479
|
+
else fail_test(test_path, "fork capture produced no result")
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Fork the booted parent, run one test under the inherited Coverage, and
|
|
484
|
+
# return its per-source counts hash, a String diagnostic on failure, or
|
|
485
|
+
# :timeout after `@capture_timeout` (see {#await_child}). Reuses the same
|
|
486
|
+
# fork + Marshal-over-pipe + hard-exit! discipline as WorkerPool/Isolation.
|
|
487
|
+
def fork_capture(abs_test, abs_sources, after_fork)
|
|
488
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @capture_timeout
|
|
489
|
+
rd, wr = IO.pipe
|
|
490
|
+
# Marshal output is binary: an un-binmoded pipe can raise
|
|
491
|
+
# Encoding::UndefinedConversionError on write, which the child's rescue then
|
|
492
|
+
# swallows, losing the real error and yielding a bare "no result".
|
|
493
|
+
rd.binmode
|
|
494
|
+
wr.binmode
|
|
495
|
+
parent = Process.pid
|
|
496
|
+
@before_fork&.call
|
|
497
|
+
pid = fork do
|
|
498
|
+
rd.close
|
|
499
|
+
Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
|
|
500
|
+
OrphanGuard.start(parent)
|
|
501
|
+
payload =
|
|
502
|
+
begin
|
|
503
|
+
ChildStdout.silence
|
|
504
|
+
# Fork-safety hook: the in-process path reconnects AR; the daemon
|
|
505
|
+
# drops its protocol channel and routes to its worker DB. Nil =
|
|
506
|
+
# no-op. Injected so this file needs neither Runner (Prism) nor Rails.
|
|
507
|
+
after_fork&.call
|
|
508
|
+
Coverage.result(clear: true, stop: false) # discard pre-test delta
|
|
509
|
+
passed = TestRunners.for(@framework).run([abs_test]).zero?
|
|
510
|
+
# {file => {lines: [...], methods: {...}}}; reduce to the counts
|
|
511
|
+
# array record() expects, keeping only our source files.
|
|
512
|
+
coverage = Coverage.result(stop: false)
|
|
513
|
+
.select { |f, _| abs_sources.include?(f) }
|
|
514
|
+
.transform_values { |v| v.is_a?(Hash) ? lines_with_called_defs(v) : v }
|
|
515
|
+
{ "passed" => passed, "coverage" => coverage,
|
|
516
|
+
"loaded_files" => capture_loaded_files }
|
|
517
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
518
|
+
# Stringify (an arbitrary Exception may not marshal); the parent
|
|
519
|
+
# surfaces this under --verbose. A String marshals safely over the pipe.
|
|
520
|
+
"#{e.class}: #{e.message}#{e.backtrace&.first ? " @ #{e.backtrace.first}" : ''}"
|
|
521
|
+
end
|
|
522
|
+
begin
|
|
523
|
+
wr.write(Marshal.dump(payload))
|
|
524
|
+
rescue StandardError # rubocop:disable Lint/SuppressedException
|
|
525
|
+
# pipe gone; parent records "no result"
|
|
526
|
+
ensure
|
|
527
|
+
wr.close
|
|
528
|
+
exit!(0) # skip at_exit so the parent suite's autorun never re-fires here
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
wr.close
|
|
532
|
+
# Marshal.load reads exactly one object, not until EOF: a process that the
|
|
533
|
+
# test leaves running (even outside the group) can hold the pipe open.
|
|
534
|
+
status, payload = await_child(pid, deadline) { read_marshal(rd) }
|
|
535
|
+
return :timeout unless status
|
|
536
|
+
|
|
537
|
+
# An empty pipe means the child died before writing (e.g. a hard crash,
|
|
538
|
+
# OOM, or a signal from the test's own subprocess handling). Report HOW it
|
|
539
|
+
# died (exit status / signal) as a diagnostic string so --verbose has
|
|
540
|
+
# something actionable instead of a silent "no result".
|
|
541
|
+
return "child wrote no result (#{describe_status(status)})" if payload.nil?
|
|
542
|
+
|
|
543
|
+
payload
|
|
544
|
+
rescue StandardError => e
|
|
545
|
+
"parent could not read capture result: #{e.class}: #{e.message}"
|
|
546
|
+
ensure
|
|
547
|
+
@fork_cleanup&.call(pid) if pid
|
|
548
|
+
[rd, wr].compact.each { |io| io.close unless io.closed? }
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# Human description of a child Process::Status for capture diagnostics.
|
|
552
|
+
#
|
|
553
|
+
# @api private
|
|
554
|
+
# @param status [Process::Status] the reaped child status.
|
|
555
|
+
# @return [String] e.g. "killed by signal 9 (SIGKILL)" or "exit status 1".
|
|
556
|
+
def describe_status(status)
|
|
557
|
+
if status.signaled?
|
|
558
|
+
sig = status.termsig
|
|
559
|
+
"killed by signal #{sig}#{Signal.signame(sig) ? " (SIG#{Signal.signame(sig)})" : ''}"
|
|
560
|
+
else
|
|
561
|
+
"exit status #{status.exitstatus.inspect}"
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# Spawns a fresh `ruby` reading an inline script from stdin. A fork would
|
|
566
|
+
# miss already-loaded app lines, so Coverage must start in a clean process
|
|
567
|
+
# before any source is loaded. Returns the wrapped capture payload
|
|
568
|
+
# (`passed` + `coverage`), or nil when the subprocess failed (logged + skipped).
|
|
569
|
+
def capture(test_path)
|
|
570
|
+
status, out = spawn_script(subprocess_script(test_path))
|
|
571
|
+
return fail_test(test_path, "timed out after #{@capture_timeout}s") unless status
|
|
572
|
+
return fail_test(test_path, "subprocess exited #{status.exitstatus}") unless status.success?
|
|
573
|
+
|
|
574
|
+
parsed = JSON.parse(out)
|
|
575
|
+
return fail_test(test_path, "invalid coverage output: missing pass/coverage payload") unless wrapped_capture?(parsed)
|
|
576
|
+
|
|
577
|
+
parsed
|
|
578
|
+
rescue JSON::ParserError => e
|
|
579
|
+
fail_test(test_path, "invalid coverage output: #{e.message}")
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
# Records a failed coverage capture.
|
|
583
|
+
#
|
|
584
|
+
# @api private
|
|
585
|
+
# @param test_path [String] test file path.
|
|
586
|
+
# @param reason [String] failure reason.
|
|
587
|
+
# @return [void]
|
|
588
|
+
def fail_test(test_path, reason)
|
|
589
|
+
rel = relativize(test_path)
|
|
590
|
+
@failed_test_files << rel
|
|
591
|
+
warn "[mutineer] coverage skipped for #{rel}: #{reason}"
|
|
592
|
+
nil
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
# True when `payload` is the wrapped capture JSON/Marshal contract
|
|
596
|
+
# (`passed` + `coverage`), not a raw Coverage.result hash.
|
|
597
|
+
#
|
|
598
|
+
# @api private
|
|
599
|
+
# @param payload [Object] parsed subprocess output or forked Marshal value.
|
|
600
|
+
# @return [Boolean]
|
|
601
|
+
def wrapped_capture?(payload)
|
|
602
|
+
payload.is_a?(Hash) && payload.key?("passed") && payload.key?("coverage")
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# Records a wrapped capture: assertion failures go to {#failed_clean_tests};
|
|
606
|
+
# successful coverage is inverted into the map. Capture crashes stay in
|
|
607
|
+
# {#failed_test_files} via {#fail_test}.
|
|
608
|
+
#
|
|
609
|
+
# @api private
|
|
610
|
+
# @param test_path [String] test file path.
|
|
611
|
+
# @param payload [Hash] wrapped capture with string keys.
|
|
612
|
+
# @return [void]
|
|
613
|
+
def accept_capture_payload(test_path, payload)
|
|
614
|
+
unless wrapped_capture?(payload)
|
|
615
|
+
fail_test(test_path, "invalid coverage output: missing pass/coverage payload")
|
|
616
|
+
return
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
record_loaded(payload["loaded_files"])
|
|
620
|
+
record_load(payload["load_coverage"])
|
|
621
|
+
|
|
622
|
+
unless payload["passed"]
|
|
623
|
+
@failed_clean_tests << relativize(test_path)
|
|
624
|
+
return
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
coverage = payload["coverage"]
|
|
628
|
+
record(coverage, test_path) if coverage.is_a?(Hash)
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# Re-runs each successfully captured test on a cache hit. Digest equality
|
|
632
|
+
# cannot prove the current unmutated suite still passes.
|
|
633
|
+
#
|
|
634
|
+
# @api private
|
|
635
|
+
# @param after_fork [Proc, nil] boot-mode fork hook.
|
|
636
|
+
# @return [void]
|
|
637
|
+
def verify_cached_clean(after_fork: nil)
|
|
638
|
+
@test_paths.each do |test_path|
|
|
639
|
+
rel = relativize(test_path)
|
|
640
|
+
next if @failed_test_files.include?(rel)
|
|
641
|
+
|
|
642
|
+
ok = if @boot_path
|
|
643
|
+
fork_clean_pass?([absolute(test_path)], after_fork)
|
|
644
|
+
else
|
|
645
|
+
subprocess_clean_pass?([test_path])
|
|
646
|
+
end
|
|
647
|
+
@failed_clean_tests << rel unless ok
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
# Re-runs tests whose previous capture crashed. A fixed helper is invisible
|
|
652
|
+
# to the source/test digest when that capture never recorded `loaded_files`.
|
|
653
|
+
#
|
|
654
|
+
# @api private
|
|
655
|
+
# @param after_fork [Proc, nil] boot-mode fork hook.
|
|
656
|
+
# @return [void]
|
|
657
|
+
def retry_failed_captures(after_fork)
|
|
658
|
+
pending = @failed_test_files.dup
|
|
659
|
+
return if pending.empty?
|
|
660
|
+
|
|
661
|
+
@failed_test_files = []
|
|
662
|
+
abs_sources = abs_source_paths
|
|
663
|
+
pending.each do |rel|
|
|
664
|
+
test_path = @test_paths.find { |t| relativize(t) == rel } || rel
|
|
665
|
+
if @boot_path
|
|
666
|
+
accept_fork_payload(test_path, timed(test_path) { fork_capture(absolute(test_path), abs_sources, after_fork) })
|
|
667
|
+
else
|
|
668
|
+
payload = timed(test_path) { capture(test_path) }
|
|
669
|
+
accept_capture_payload(test_path, payload) if payload
|
|
670
|
+
end
|
|
671
|
+
end
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
# Runs every successfully captured test together. Per-file capture can miss a
|
|
675
|
+
# failure that only appears when covering files share one process.
|
|
676
|
+
#
|
|
677
|
+
# @api private
|
|
678
|
+
# @param after_fork [Proc, nil] boot-mode fork hook.
|
|
679
|
+
# @return [void]
|
|
680
|
+
def verify_combined_clean(after_fork: nil)
|
|
681
|
+
runnable = @test_paths.reject { |t| @failed_test_files.include?(relativize(t)) }
|
|
682
|
+
return if runnable.size < 2
|
|
683
|
+
return unless @failed_clean_tests.empty?
|
|
684
|
+
|
|
685
|
+
ok = if @boot_path
|
|
686
|
+
fork_clean_pass?(runnable.map { |t| absolute(t) }, after_fork)
|
|
687
|
+
else
|
|
688
|
+
subprocess_clean_pass?(runnable)
|
|
689
|
+
end
|
|
690
|
+
@failed_clean_tests << "combined suite" unless ok
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
# Runs test files in a fresh interpreter and returns whether they passed.
|
|
694
|
+
#
|
|
695
|
+
# @api private
|
|
696
|
+
# @param test_paths [Array<String>] test file paths.
|
|
697
|
+
# @return [Boolean]
|
|
698
|
+
def subprocess_clean_pass?(test_paths)
|
|
699
|
+
status, = spawn_script(clean_check_script(test_paths), result: false)
|
|
700
|
+
status&.success? || false
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
# Runs `script` in a fresh `ruby -` that reads the script from stdin. The
|
|
704
|
+
# child's stdout goes to File::NULL, so test output never reaches the user
|
|
705
|
+
# or the result. With `result: true`, the child writes its result as one
|
|
706
|
+
# line to fd {RESULT_FD}, a pipe that only the script uses. The child's
|
|
707
|
+
# stderr is the parent's stderr, so warnings from the script reach the
|
|
708
|
+
# user. A wall clock of `@capture_timeout` bounds the whole call (see
|
|
709
|
+
# {#await_child}), so a hung test cannot wedge the run.
|
|
710
|
+
#
|
|
711
|
+
# The parent reads one line, not until EOF: a process that a test leaves
|
|
712
|
+
# running can inherit fd {RESULT_FD} (a `fork` without `exec` keeps it
|
|
713
|
+
# despite close-on-exec) and hold the pipe open long after the child exits.
|
|
714
|
+
# A clean check reports only through its exit status, so it gets no pipe.
|
|
715
|
+
#
|
|
716
|
+
# @api private
|
|
717
|
+
# @param script [String] Ruby script text.
|
|
718
|
+
# @param result [Boolean] whether to open the result pipe on fd {RESULT_FD}.
|
|
719
|
+
# @return [Array(Process::Status, String)] the exit status and the line the
|
|
720
|
+
# child wrote to fd {RESULT_FD} (`""` without one); `[nil, ""]` after a
|
|
721
|
+
# timeout.
|
|
722
|
+
def spawn_script(script, result: true)
|
|
723
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @capture_timeout
|
|
724
|
+
script_rd, script_wr = IO.pipe
|
|
725
|
+
result_rd, result_wr = IO.pipe if result
|
|
726
|
+
options = { in: script_rd, out: File::NULL, pgroup: true }
|
|
727
|
+
options[RESULT_FD] = result_wr if result
|
|
728
|
+
pid = Process.spawn(RbConfig.ruby, "-", **options)
|
|
729
|
+
script_rd.close
|
|
730
|
+
result_wr&.close
|
|
731
|
+
begin
|
|
732
|
+
script_wr.write(script)
|
|
733
|
+
rescue Errno::EPIPE
|
|
734
|
+
# The child exited before reading its script; await_child still reaps it.
|
|
735
|
+
end
|
|
736
|
+
script_wr.close
|
|
737
|
+
status, out = await_child(pid, deadline) { read_result_line(result_rd) if result }
|
|
738
|
+
[status, out.to_s]
|
|
739
|
+
ensure
|
|
740
|
+
[script_rd, script_wr, result_rd, result_wr].compact.each { |io| io.close unless io.closed? }
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
# Reads the one result line a {#spawn_script} child writes to fd
|
|
744
|
+
# {RESULT_FD}.
|
|
745
|
+
#
|
|
746
|
+
# @api private
|
|
747
|
+
# @param io [IO] the parent end of the result pipe.
|
|
748
|
+
# @return [String] the line, or `""` at end of file.
|
|
749
|
+
def read_result_line(io) = io.gets.to_s
|
|
750
|
+
|
|
751
|
+
# Reads the one Marshal object a forked capture child writes, without
|
|
752
|
+
# waiting for end of file.
|
|
753
|
+
#
|
|
754
|
+
# @api private
|
|
755
|
+
# @param io [IO] the parent end of the binary result pipe.
|
|
756
|
+
# @return [Object, nil] the object, or nil when the child wrote none.
|
|
757
|
+
def read_marshal(io)
|
|
758
|
+
Marshal.load(io)
|
|
759
|
+
rescue EOFError
|
|
760
|
+
nil
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
# Waits for capture child `pid`, which leads its own process group, while
|
|
764
|
+
# a thread runs the block to read the child's result. One `deadline`
|
|
765
|
+
# bounds the run, the read, and the reap. Past it, the whole group gets
|
|
766
|
+
# SIGKILL, so a process the test started does not outlive a hung capture.
|
|
767
|
+
# Process.detach owns reaping. The same deadline and group kill as
|
|
768
|
+
# DaemonServer#wait_verdict, plus a reader, because the child reports
|
|
769
|
+
# through a pipe.
|
|
770
|
+
#
|
|
771
|
+
# A child that exits before the deadline has written its result, so the
|
|
772
|
+
# reader gets the time left, and at least {RESULT_GRACE} seconds, to finish.
|
|
773
|
+
#
|
|
774
|
+
# @api private
|
|
775
|
+
# @param pid [Integer] child pid, also its process group id.
|
|
776
|
+
# @param deadline [Float] a CLOCK_MONOTONIC time.
|
|
777
|
+
# @yieldreturn [Object] what the child wrote.
|
|
778
|
+
# @return [Array(Process::Status, Object)] the exit status and the block's
|
|
779
|
+
# value (nil when the reader did not finish); `[nil, nil]` after a timeout.
|
|
780
|
+
def await_child(pid, deadline, &read)
|
|
781
|
+
waiter = Process.detach(pid)
|
|
782
|
+
reader = Thread.new(&read)
|
|
783
|
+
reader.report_on_exception = false
|
|
784
|
+
return [nil, nil] unless waiter.join(remaining(deadline))
|
|
785
|
+
|
|
786
|
+
[waiter.value, reader.join([remaining(deadline), RESULT_GRACE].max)&.value]
|
|
787
|
+
ensure
|
|
788
|
+
kill_group(pid, waiter) if waiter&.alive?
|
|
789
|
+
reader&.kill
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
# Seconds left before `deadline`, never negative.
|
|
793
|
+
#
|
|
794
|
+
# @api private
|
|
795
|
+
# @param deadline [Float] a CLOCK_MONOTONIC time.
|
|
796
|
+
# @return [Float]
|
|
797
|
+
def remaining(deadline)
|
|
798
|
+
[deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
# Ruby source that opens the result channel in a {#spawn_script} child. The
|
|
802
|
+
# script runs it first, so no file that a test opens can take fd
|
|
803
|
+
# {RESULT_FD}. Close-on-exec keeps the fd out of the test's own
|
|
804
|
+
# subprocesses.
|
|
805
|
+
#
|
|
806
|
+
# @api private
|
|
807
|
+
# @return [String] Ruby script text.
|
|
808
|
+
def result_channel_expression
|
|
809
|
+
"_result = IO.new(#{RESULT_FD}, \"w\"); _result.close_on_exec = true"
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
# Runs test files in a fork of the booted parent and returns whether they passed.
|
|
813
|
+
# Bounded by `@capture_timeout` so a hung child cannot block the CLI.
|
|
814
|
+
#
|
|
815
|
+
# @api private
|
|
816
|
+
# @param abs_tests [Array<String>] absolute test file paths.
|
|
817
|
+
# @param after_fork [Proc, nil] boot-mode fork hook.
|
|
818
|
+
# @return [Boolean]
|
|
819
|
+
def fork_clean_pass?(abs_tests, after_fork)
|
|
820
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @capture_timeout
|
|
821
|
+
rd, wr = IO.pipe
|
|
822
|
+
rd.binmode
|
|
823
|
+
wr.binmode
|
|
824
|
+
parent = Process.pid
|
|
825
|
+
@before_fork&.call
|
|
826
|
+
pid = fork do
|
|
827
|
+
rd.close
|
|
828
|
+
Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
|
|
829
|
+
OrphanGuard.start(parent)
|
|
830
|
+
begin
|
|
831
|
+
ChildStdout.silence
|
|
832
|
+
after_fork&.call
|
|
833
|
+
Coverage.result(clear: true, stop: false) if Coverage.running?
|
|
834
|
+
wr.write(Marshal.dump(TestRunners.for(@framework).run(abs_tests).zero?))
|
|
835
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
836
|
+
wr.write(Marshal.dump(false))
|
|
837
|
+
ensure
|
|
838
|
+
wr.close
|
|
839
|
+
exit!(0)
|
|
840
|
+
end
|
|
841
|
+
end
|
|
842
|
+
wr.close
|
|
843
|
+
_, passed = await_child(pid, deadline) { read_marshal(rd) }
|
|
844
|
+
passed == true
|
|
845
|
+
rescue StandardError
|
|
846
|
+
false
|
|
847
|
+
ensure
|
|
848
|
+
@fork_cleanup&.call(pid) if pid
|
|
849
|
+
[rd, wr].compact.each { |io| io.close unless io.closed? }
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
# SIGKILLs capture child `pid` and its process group, then waits for
|
|
853
|
+
# `waiter` to reap the child. Falls back to the pid alone when the group
|
|
854
|
+
# does not exist yet.
|
|
855
|
+
#
|
|
856
|
+
# @api private
|
|
857
|
+
# @param pid [Integer] child pid, also its process group id.
|
|
858
|
+
# @param waiter [Thread] the Process.detach thread for `pid`.
|
|
859
|
+
# @return [void]
|
|
860
|
+
def kill_group(pid, waiter)
|
|
861
|
+
begin
|
|
862
|
+
Process.kill(:KILL, -pid)
|
|
863
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
864
|
+
# No group yet: the child has not run setpgid. Never signal a reaped pid.
|
|
865
|
+
if waiter.alive?
|
|
866
|
+
Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
867
|
+
end
|
|
868
|
+
end
|
|
869
|
+
waiter.join
|
|
870
|
+
end
|
|
871
|
+
|
|
872
|
+
# Builds a pass/fail-only subprocess script (no coverage instrumentation).
|
|
873
|
+
#
|
|
874
|
+
# @api private
|
|
875
|
+
# @param test_paths [Array<String>] test file paths.
|
|
876
|
+
# @return [String] Ruby script text.
|
|
877
|
+
def clean_check_script(test_paths)
|
|
878
|
+
@framework == "rspec" ? rspec_clean_check_script(test_paths) : minitest_clean_check_script(test_paths)
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
# Minitest clean-suite check. Preloads configured sources and `--require`
|
|
882
|
+
# files like capture and standalone {Runner.execute}, so tests that rely on
|
|
883
|
+
# that preload stay green.
|
|
884
|
+
#
|
|
885
|
+
# @api private
|
|
886
|
+
# @param test_paths [Array<String>] test file paths.
|
|
887
|
+
# @return [String] Ruby script text.
|
|
888
|
+
def minitest_clean_check_script(test_paths)
|
|
889
|
+
loads = Array(test_paths).map { |t| "load #{absolute(t).inspect}" }.join("\n")
|
|
890
|
+
<<~RUBY
|
|
891
|
+
require "minitest"
|
|
892
|
+
require "stringio"
|
|
893
|
+
def Minitest.autorun; end
|
|
894
|
+
_report = StringIO.new
|
|
895
|
+
Minitest.define_singleton_method(:plugin_mutineer_report_init) { |options| reporter << Minitest::SummaryReporter.new(_report, options) }
|
|
896
|
+
Minitest.extensions << "mutineer_report"
|
|
897
|
+
$LOAD_PATH.unshift(*#{abs_load_paths.inspect})
|
|
898
|
+
#{abs_preload_paths.inspect}.each { |f| require f }
|
|
899
|
+
#{loads}
|
|
900
|
+
_passed = Minitest.run([])
|
|
901
|
+
$stderr.write(_report.string) unless _passed
|
|
902
|
+
exit(_passed ? 0 : 1)
|
|
903
|
+
RUBY
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
# RSpec clean-suite check. Preloads configured sources and `--require`
|
|
907
|
+
# files like capture.
|
|
908
|
+
#
|
|
909
|
+
# @api private
|
|
910
|
+
# @param test_paths [Array<String>] spec file paths.
|
|
911
|
+
# @return [String] Ruby script text.
|
|
912
|
+
def rspec_clean_check_script(test_paths)
|
|
913
|
+
specs = Array(test_paths).map { |t| absolute(t).inspect }.join(", ")
|
|
914
|
+
<<~RUBY
|
|
915
|
+
require "stringio"
|
|
916
|
+
begin
|
|
917
|
+
require "rspec/core"
|
|
918
|
+
rescue LoadError
|
|
919
|
+
exit 3
|
|
920
|
+
end
|
|
921
|
+
RSpec::Core::Runner.disable_autorun!
|
|
922
|
+
$LOAD_PATH.unshift(*#{abs_load_paths.inspect})
|
|
923
|
+
#{abs_preload_paths.inspect}.each { |f| require f }
|
|
924
|
+
_sink = StringIO.new
|
|
925
|
+
status = RSpec::Core::Runner.run(["--no-color", #{specs}], _sink, _sink)
|
|
926
|
+
$stderr.write(_sink.string) unless status.zero?
|
|
927
|
+
exit(status.zero? ? 0 : 1)
|
|
928
|
+
RUBY
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
# Builds the framework-specific subprocess script.
|
|
932
|
+
#
|
|
933
|
+
# @api private
|
|
934
|
+
# @param test_path [String] test file path.
|
|
935
|
+
# @return [String] Ruby script text.
|
|
936
|
+
def subprocess_script(test_path)
|
|
937
|
+
@framework == "rspec" ? rspec_subprocess_script(test_path) : minitest_subprocess_script(test_path)
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
# Builds the minitest subprocess script.
|
|
941
|
+
#
|
|
942
|
+
# @api private
|
|
943
|
+
# @param test_path [String] test file path.
|
|
944
|
+
# @return [String] Ruby script text.
|
|
945
|
+
def minitest_subprocess_script(test_path)
|
|
946
|
+
<<~RUBY
|
|
947
|
+
#{result_channel_expression}
|
|
948
|
+
require "coverage"
|
|
949
|
+
require "json"
|
|
950
|
+
require "minitest"
|
|
951
|
+
require "stringio"
|
|
952
|
+
def Minitest.autorun; end
|
|
953
|
+
_report = StringIO.new
|
|
954
|
+
Minitest.define_singleton_method(:plugin_mutineer_report_init) { |options| reporter << Minitest::SummaryReporter.new(_report, options) }
|
|
955
|
+
Minitest.extensions << "mutineer_report"
|
|
956
|
+
Coverage.start(lines: true, methods: true)
|
|
957
|
+
$LOAD_PATH.unshift(*#{abs_load_paths.inspect})
|
|
958
|
+
#{abs_preload_paths.inspect}.each { |f| require f }
|
|
959
|
+
_load = #{load_coverage_expression}
|
|
960
|
+
load #{absolute(test_path).inspect}
|
|
961
|
+
_passed = Minitest.run([])
|
|
962
|
+
$stderr.write(_report.string) unless _passed
|
|
963
|
+
_coverage = Coverage.result.transform_values { |v| v.is_a?(Hash) ? v[:lines] : v }
|
|
964
|
+
_result.puts JSON.generate("passed" => _passed == true, "coverage" => _coverage,
|
|
965
|
+
"load_coverage" => _load,
|
|
966
|
+
"loaded_files" => #{loaded_files_expression})
|
|
967
|
+
_result.close
|
|
968
|
+
RUBY
|
|
969
|
+
end
|
|
970
|
+
|
|
971
|
+
# Same coverage-JSON contract as the minitest path, but driven by RSpec:
|
|
972
|
+
# require rspec/core lazily, require the sources under Coverage, then run the
|
|
973
|
+
# one spec via RSpec::Core::Runner. The JSON goes to the result channel (see
|
|
974
|
+
# {#spawn_script}), so spec output cannot corrupt it. A missing rspec makes
|
|
975
|
+
# the script exit non-zero -> capture() records a skipped (incomplete-map)
|
|
976
|
+
# test, with a hint.
|
|
977
|
+
def rspec_subprocess_script(test_path)
|
|
978
|
+
<<~RUBY
|
|
979
|
+
#{result_channel_expression}
|
|
980
|
+
require "coverage"
|
|
981
|
+
require "json"
|
|
982
|
+
require "stringio"
|
|
983
|
+
begin
|
|
984
|
+
require "rspec/core"
|
|
985
|
+
rescue LoadError
|
|
986
|
+
warn "[mutineer] framework 'rspec' requested but rspec is not available in the project"
|
|
987
|
+
exit 3
|
|
988
|
+
end
|
|
989
|
+
RSpec::Core::Runner.disable_autorun!
|
|
990
|
+
Coverage.start(lines: true, methods: true)
|
|
991
|
+
$LOAD_PATH.unshift(*#{abs_load_paths.inspect})
|
|
992
|
+
#{abs_preload_paths.inspect}.each { |f| require f }
|
|
993
|
+
_load = #{load_coverage_expression}
|
|
994
|
+
_sink = StringIO.new
|
|
995
|
+
_status = RSpec::Core::Runner.run(["--no-color", #{absolute(test_path).inspect}], _sink, _sink)
|
|
996
|
+
$stderr.write(_sink.string) unless _status.zero?
|
|
997
|
+
_coverage = Coverage.result.transform_values { |v| v.is_a?(Hash) ? v[:lines] : v }
|
|
998
|
+
_result.puts JSON.generate("passed" => _status.zero?, "coverage" => _coverage,
|
|
999
|
+
"load_coverage" => _load,
|
|
1000
|
+
"loaded_files" => #{loaded_files_expression})
|
|
1001
|
+
_result.close
|
|
1002
|
+
RUBY
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
# Records every source line with a non-zero execution count as covered by
|
|
1006
|
+
# this test file. Coverage.result keys are absolute; relativize and drop any
|
|
1007
|
+
# path outside the project (stdlib/gem files).
|
|
1008
|
+
def record(coverage, test_path)
|
|
1009
|
+
rel_test = relativize(test_path)
|
|
1010
|
+
each_covered_key(coverage) { |key| (@map[key] ||= []) << rel_test }
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
# Adds the configured source lines that ran at load to {#load_lines}, and
|
|
1014
|
+
# the methods called at load to {#load_methods}. Lines of other files
|
|
1015
|
+
# (tests, helpers, gems) are dropped.
|
|
1016
|
+
#
|
|
1017
|
+
# @api private
|
|
1018
|
+
# @param coverage [Hash, nil] counts per absolute file, as {#record} reads
|
|
1019
|
+
# them; a Hash entry can also hold "methods", as {#load_entry} builds it.
|
|
1020
|
+
# @return [void]
|
|
1021
|
+
def record_load(coverage)
|
|
1022
|
+
return unless coverage.is_a?(Hash)
|
|
1023
|
+
|
|
1024
|
+
@source_rels ||= @source_paths.to_set { |p| relativize(absolute(p)) }
|
|
1025
|
+
ours = coverage.select { |abs_file, _| @source_rels.include?(relativize(abs_file)) }
|
|
1026
|
+
each_covered_key(ours) { |key| @load_lines << key }
|
|
1027
|
+
ours.each do |abs_file, data|
|
|
1028
|
+
next unless data.is_a?(Hash) && data["methods"].is_a?(Array)
|
|
1029
|
+
|
|
1030
|
+
data["methods"].each { |line, column| @load_methods << "#{relativize(abs_file)}:#{line}:#{column}" }
|
|
1031
|
+
end
|
|
1032
|
+
end
|
|
1033
|
+
|
|
1034
|
+
# One file's line counts, with the `def` line of each method the test
|
|
1035
|
+
# called counted too (#209). A forked capture does not see the `def` lines
|
|
1036
|
+
# run, since the boot defined the methods before the test. Ruby counts no
|
|
1037
|
+
# line when an endless method runs, so without this its mutants would be
|
|
1038
|
+
# `no_coverage` even when a test calls it. A line where more than one
|
|
1039
|
+
# method starts is not counted: the map keys tests by line, so the test
|
|
1040
|
+
# would also be credited with the methods it did not call.
|
|
1041
|
+
#
|
|
1042
|
+
# @api private
|
|
1043
|
+
# @param data [Hash] `Coverage` result for one file, with `:lines` and `:methods`.
|
|
1044
|
+
# @return [Array, nil] line counts.
|
|
1045
|
+
def lines_with_called_defs(data)
|
|
1046
|
+
lines = data[:lines]
|
|
1047
|
+
return lines unless lines && data[:methods]
|
|
1048
|
+
|
|
1049
|
+
lines = lines.dup
|
|
1050
|
+
starts = data[:methods].keys.map { |key| key[2] }.tally
|
|
1051
|
+
data[:methods].each do |key, count|
|
|
1052
|
+
line = key[2]
|
|
1053
|
+
lines[line - 1] = [lines[line - 1].to_i, count].max if count.positive? && starts[line] == 1
|
|
1054
|
+
end
|
|
1055
|
+
lines
|
|
1056
|
+
end
|
|
1057
|
+
|
|
1058
|
+
# One file's load-time coverage in the shape {#record_load} reads: the line
|
|
1059
|
+
# counts, and the `[line, column]` of each method called at least once.
|
|
1060
|
+
# {#load_coverage_expression} builds the same shape in a capture child.
|
|
1061
|
+
#
|
|
1062
|
+
# @api private
|
|
1063
|
+
# @param lines [Array, nil] line counts.
|
|
1064
|
+
# @param methods [Hash, nil] `Coverage` method counts, keyed by
|
|
1065
|
+
# `[owner, name, start_line, start_column, end_line, end_column]`.
|
|
1066
|
+
# @return [Hash{String => Array}]
|
|
1067
|
+
def load_entry(lines, methods)
|
|
1068
|
+
{ "lines" => lines, "methods" => (methods || {}).filter_map { |key, count| key[2, 2] if count.positive? } }
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
# Ruby source of the capture child's load-time coverage: `Coverage.peek_result`
|
|
1072
|
+
# for the configured sources only, matched by real path (a Coverage key is
|
|
1073
|
+
# the path as required), so the payload does not carry every loaded gem.
|
|
1074
|
+
# Each entry has the shape {#load_entry} builds.
|
|
1075
|
+
#
|
|
1076
|
+
# @api private
|
|
1077
|
+
# @return [String] expression to embed in a capture subprocess script.
|
|
1078
|
+
def load_coverage_expression
|
|
1079
|
+
"Coverage.peek_result.select { |f, _| #{abs_source_paths.inspect}.include?((File.realpath(f) rescue f)) }" \
|
|
1080
|
+
".transform_values { |v| { \"lines\" => v[:lines], " \
|
|
1081
|
+
"\"methods\" => v[:methods].filter_map { |k, n| k[2, 2] if n.positive? } } }"
|
|
1082
|
+
end
|
|
1083
|
+
|
|
1084
|
+
# Yields a "file:line" key for every project line with a non-zero count.
|
|
1085
|
+
#
|
|
1086
|
+
# @api private
|
|
1087
|
+
# @param coverage [Hash] counts per absolute file: an Array, or a Hash with "lines".
|
|
1088
|
+
# @yieldparam key [String] the "file:line" key.
|
|
1089
|
+
# @return [void]
|
|
1090
|
+
def each_covered_key(coverage)
|
|
1091
|
+
coverage.each do |abs_file, data|
|
|
1092
|
+
rel = relativize(abs_file)
|
|
1093
|
+
next if rel.start_with?("/") # outside project_root: not our source
|
|
1094
|
+
|
|
1095
|
+
counts = data.is_a?(Hash) ? data["lines"] : data
|
|
1096
|
+
next unless counts.is_a?(Array) # a Coverage mode without line counts
|
|
1097
|
+
|
|
1098
|
+
counts.each_with_index { |count, idx| yield "#{rel}:#{idx + 1}" if count&.positive? }
|
|
1099
|
+
end
|
|
1100
|
+
end
|
|
1101
|
+
|
|
1102
|
+
# Ruby source of the child-side `$LOADED_FEATURES` filter (project `.rb` files).
|
|
1103
|
+
#
|
|
1104
|
+
# @api private
|
|
1105
|
+
# @return [String] expression to embed in a capture subprocess script.
|
|
1106
|
+
def loaded_files_expression
|
|
1107
|
+
root = project_root_real
|
|
1108
|
+
prefix = root.end_with?("/") ? root : "#{root}/"
|
|
1109
|
+
"begin; _root = #{prefix.inspect}; $LOADED_FEATURES.filter_map { |f| next unless f.end_with?(\".rb\"); abs = (File.realpath(f) rescue next); abs if abs.start_with?(_root) }; rescue StandardError; []; end"
|
|
1110
|
+
end
|
|
1111
|
+
|
|
1112
|
+
# Canonical project root for loaded-feature matching (`/var` vs `/private/var`).
|
|
1113
|
+
#
|
|
1114
|
+
# @api private
|
|
1115
|
+
# @return [String] realpath of the project root when it exists.
|
|
1116
|
+
def project_root_real = ProjectPath.root_real(@project_root)
|
|
1117
|
+
|
|
1118
|
+
# Project-local `.rb` files loaded in this process at capture time.
|
|
1119
|
+
#
|
|
1120
|
+
# @api private
|
|
1121
|
+
# @return [Array<String>] absolute realpaths.
|
|
1122
|
+
def capture_loaded_files
|
|
1123
|
+
prefix = project_root_real
|
|
1124
|
+
prefix = "#{prefix}/" unless prefix.end_with?("/")
|
|
1125
|
+
$LOADED_FEATURES.filter_map do |f|
|
|
1126
|
+
next unless f.end_with?(".rb")
|
|
1127
|
+
|
|
1128
|
+
abs = File.realpath(f)
|
|
1129
|
+
abs if abs.start_with?(prefix)
|
|
1130
|
+
rescue Errno::ENOENT
|
|
1131
|
+
nil
|
|
1132
|
+
end
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1135
|
+
# Fingerprints project-local support files from a capture payload.
|
|
1136
|
+
#
|
|
1137
|
+
# @api private
|
|
1138
|
+
# @param paths [Array, nil] absolute loaded-file paths.
|
|
1139
|
+
# @return [void]
|
|
1140
|
+
def record_loaded(paths)
|
|
1141
|
+
Array(paths).each do |raw|
|
|
1142
|
+
next unless raw.is_a?(String) && File.file?(raw)
|
|
1143
|
+
|
|
1144
|
+
abs = File.realpath(raw)
|
|
1145
|
+
rel = loaded_relative(abs)
|
|
1146
|
+
next unless rel
|
|
1147
|
+
next unless rel.end_with?(".rb")
|
|
1148
|
+
next if rel.start_with?("vendor/bundle/") || rel.start_with?("node_modules/")
|
|
1149
|
+
|
|
1150
|
+
@loaded_dependencies[rel] = file_fingerprint(abs)
|
|
1151
|
+
end
|
|
1152
|
+
end
|
|
1153
|
+
|
|
1154
|
+
# Path of `abs` relative to the real project root, or nil when outside it.
|
|
1155
|
+
#
|
|
1156
|
+
# @api private
|
|
1157
|
+
# @param abs [String] absolute realpath.
|
|
1158
|
+
# @return [String, nil]
|
|
1159
|
+
def loaded_relative(abs)
|
|
1160
|
+
root = project_root_real
|
|
1161
|
+
prefix = root.end_with?("/") ? root : "#{root}/"
|
|
1162
|
+
return unless abs.start_with?(prefix)
|
|
1163
|
+
|
|
1164
|
+
abs.delete_prefix(prefix)
|
|
1165
|
+
end
|
|
1166
|
+
|
|
1167
|
+
# Byte fingerprint of a file for cache dependency checks.
|
|
1168
|
+
#
|
|
1169
|
+
# @api private
|
|
1170
|
+
# @param abs [String] absolute path.
|
|
1171
|
+
# @return [String] hex digest.
|
|
1172
|
+
def file_fingerprint(abs)
|
|
1173
|
+
content = File.binread(abs)
|
|
1174
|
+
Digest::SHA256.hexdigest("#{content.bytesize}\0#{content}")
|
|
1175
|
+
end
|
|
1176
|
+
|
|
1177
|
+
# True when the cached map recorded support-file fingerprints and they still
|
|
1178
|
+
# match. Missing validity data is a miss (rebuild).
|
|
1179
|
+
#
|
|
1180
|
+
# @api private
|
|
1181
|
+
# @param cached [Hash] parsed coverage.json.
|
|
1182
|
+
# @return [Boolean]
|
|
1183
|
+
def dependencies_match?(cached)
|
|
1184
|
+
deps = cached["dependencies"]
|
|
1185
|
+
return false unless deps.is_a?(Hash)
|
|
1186
|
+
|
|
1187
|
+
deps.all? do |rel, fingerprint|
|
|
1188
|
+
abs = absolute(rel)
|
|
1189
|
+
File.file?(abs) && file_fingerprint(abs) == fingerprint
|
|
1190
|
+
end
|
|
1191
|
+
end
|
|
1192
|
+
|
|
1193
|
+
# Digest each file's ROLE + relative path + content length + content, plus
|
|
1194
|
+
# the load_paths. Without role/path/length delimiters the digest collides
|
|
1195
|
+
# (("ab","c") == ("a","bc")) and is blind to source/test role swaps, silently
|
|
1196
|
+
# accepting a stale cached map.
|
|
1197
|
+
def compute_digest
|
|
1198
|
+
d = Digest::SHA256.new
|
|
1199
|
+
digest_group(d, "source", @source_paths)
|
|
1200
|
+
digest_group(d, "test", @test_paths)
|
|
1201
|
+
# One group per file: digest_group sorts, and the load order matters.
|
|
1202
|
+
@require_paths.each { |p| digest_group(d, "require", [digest_path(p)]) }
|
|
1203
|
+
digest_group(d, "boot", [digest_path(@boot_path)]) if @boot_path
|
|
1204
|
+
ownership_paths.each do |rel|
|
|
1205
|
+
d.update("owner\0")
|
|
1206
|
+
d.update(rel)
|
|
1207
|
+
d.update("\0")
|
|
1208
|
+
end
|
|
1209
|
+
@load_paths.sort.each { |lp| d.update("loadpath\0#{lp}\0") }
|
|
1210
|
+
d.update("framework\0#{@framework}\0")
|
|
1211
|
+
d.hexdigest
|
|
1212
|
+
end
|
|
1213
|
+
|
|
1214
|
+
# Longer source files that can take a split test from a configured source.
|
|
1215
|
+
# Only their presence is digested. A content edit of a sibling does not
|
|
1216
|
+
# change pairing, and it does not rebuild coverage. Each directory is
|
|
1217
|
+
# listed once, even when many sources share it.
|
|
1218
|
+
#
|
|
1219
|
+
# @return [Array<String>] project-relative paths, sorted.
|
|
1220
|
+
def ownership_paths
|
|
1221
|
+
root = File.expand_path(@project_root)
|
|
1222
|
+
listings = {}
|
|
1223
|
+
paths = @source_paths.flat_map do |source|
|
|
1224
|
+
rel = relativize(absolute(source))
|
|
1225
|
+
next [] if rel.start_with?("/")
|
|
1226
|
+
|
|
1227
|
+
base, = Pairing.logical_path(rel)
|
|
1228
|
+
name = File.basename(base)
|
|
1229
|
+
dir = File.dirname(base)
|
|
1230
|
+
folders = [dir == "." ? nil : dir]
|
|
1231
|
+
%w[app lib].each { |prefix| folders << (dir == "." ? prefix : File.join(prefix, dir)) }
|
|
1232
|
+
folders.uniq.flat_map do |folder|
|
|
1233
|
+
abs = folder ? File.join(root, folder) : root
|
|
1234
|
+
entries = listings.fetch(abs) do
|
|
1235
|
+
listings[abs] = File.directory?(abs) ? Dir.children(abs) : []
|
|
1236
|
+
end
|
|
1237
|
+
entries.filter_map do |entry|
|
|
1238
|
+
next unless entry.end_with?(".rb")
|
|
1239
|
+
|
|
1240
|
+
stem = entry.delete_suffix(".rb")
|
|
1241
|
+
next unless stem.start_with?("#{name}_") && stem.length > name.length
|
|
1242
|
+
|
|
1243
|
+
folder ? File.join(folder, entry) : entry
|
|
1244
|
+
end
|
|
1245
|
+
end
|
|
1246
|
+
end
|
|
1247
|
+
paths.uniq.sort
|
|
1248
|
+
end
|
|
1249
|
+
|
|
1250
|
+
# boot_path and require_paths are require-style paths (e.g. "config/environment",
|
|
1251
|
+
# no extension); resolve one to the file `require` loads: the path itself, then
|
|
1252
|
+
# ".rb", then a native extension. A folder of the same name (lib/my_gem/ next to
|
|
1253
|
+
# lib/my_gem.rb) is not the file.
|
|
1254
|
+
def digest_path(path)
|
|
1255
|
+
["", ".rb", ".#{RbConfig::CONFIG['DLEXT']}"].map { |ext| "#{path}#{ext}" }
|
|
1256
|
+
.find { |p| File.file?(absolute(p)) } || "#{path}.rb"
|
|
1257
|
+
end
|
|
1258
|
+
|
|
1259
|
+
# Groups a digest with its role and paths.
|
|
1260
|
+
#
|
|
1261
|
+
# @api private
|
|
1262
|
+
# @param digest [String] digest string.
|
|
1263
|
+
# @param role [String] digest role.
|
|
1264
|
+
# @param paths [Array<String>] paths in the digest group.
|
|
1265
|
+
# @return [Array(String, String, Array<String>)] grouped digest data.
|
|
1266
|
+
def digest_group(digest, role, paths)
|
|
1267
|
+
paths.sort.each do |p|
|
|
1268
|
+
content = File.read(absolute(p))
|
|
1269
|
+
digest.update(role)
|
|
1270
|
+
digest.update("\0")
|
|
1271
|
+
digest.update(relativize(absolute(p)))
|
|
1272
|
+
digest.update("\0")
|
|
1273
|
+
digest.update(content.bytesize.to_s)
|
|
1274
|
+
digest.update("\0")
|
|
1275
|
+
digest.update(content)
|
|
1276
|
+
digest.update("\0")
|
|
1277
|
+
end
|
|
1278
|
+
end
|
|
1279
|
+
|
|
1280
|
+
# A configured source that resolves outside project_root would silently be
|
|
1281
|
+
# dropped (its coverage relativizes to an absolute path). Warn instead.
|
|
1282
|
+
def warn_external_sources
|
|
1283
|
+
@source_paths.each do |p|
|
|
1284
|
+
next unless relativize(absolute(p)).start_with?("/")
|
|
1285
|
+
|
|
1286
|
+
warn "[mutineer] source #{p} is outside project root #{@project_root}; " \
|
|
1287
|
+
"its coverage will be ignored"
|
|
1288
|
+
end
|
|
1289
|
+
end
|
|
1290
|
+
|
|
1291
|
+
# Returns the cache path.
|
|
1292
|
+
#
|
|
1293
|
+
# @api private
|
|
1294
|
+
# @return [String] cache file path.
|
|
1295
|
+
def cache_path = File.join(@cache_dir, "coverage.json")
|
|
1296
|
+
|
|
1297
|
+
# Reads the coverage cache.
|
|
1298
|
+
#
|
|
1299
|
+
# @api private
|
|
1300
|
+
# @return [Hash, nil] cached payload.
|
|
1301
|
+
def read_cache
|
|
1302
|
+
return nil unless File.exist?(cache_path)
|
|
1303
|
+
|
|
1304
|
+
JSON.parse(File.read(cache_path))
|
|
1305
|
+
rescue JSON::ParserError
|
|
1306
|
+
nil # corrupt cache: rebuild from scratch
|
|
1307
|
+
end
|
|
1308
|
+
|
|
1309
|
+
# Saves the coverage cache.
|
|
1310
|
+
#
|
|
1311
|
+
# @api private
|
|
1312
|
+
# @return [void]
|
|
1313
|
+
def save
|
|
1314
|
+
return unless @failed_clean_tests.empty?
|
|
1315
|
+
|
|
1316
|
+
FileUtils.mkdir_p(@cache_dir)
|
|
1317
|
+
data = { "digest" => @digest, "failed_test_files" => @failed_test_files,
|
|
1318
|
+
"dependencies" => @loaded_dependencies, "map" => @map,
|
|
1319
|
+
"load_lines" => @load_lines.to_a.sort, "load_methods" => @load_methods.to_a.sort,
|
|
1320
|
+
"timings" => @timings }
|
|
1321
|
+
tmp = "#{cache_path}.tmp"
|
|
1322
|
+
File.write(tmp, JSON.generate(data))
|
|
1323
|
+
File.rename(tmp, cache_path) # atomic swap
|
|
1324
|
+
end
|
|
1325
|
+
|
|
1326
|
+
# Warns when coverage capture was incomplete.
|
|
1327
|
+
#
|
|
1328
|
+
# @api private
|
|
1329
|
+
# @return [void]
|
|
1330
|
+
def warn_incomplete
|
|
1331
|
+
warn "[mutineer] cached coverage map may be incomplete; these test files " \
|
|
1332
|
+
"failed to contribute: #{@failed_test_files.join(', ')}"
|
|
1333
|
+
end
|
|
1334
|
+
|
|
1335
|
+
# Returns absolute source paths.
|
|
1336
|
+
#
|
|
1337
|
+
# @return [Array<String>] absolute source paths.
|
|
1338
|
+
def abs_source_paths = @source_paths.map { |p| absolute(p) }
|
|
1339
|
+
|
|
1340
|
+
# The sources, then the `--require` files, in the order standalone
|
|
1341
|
+
# {Runner.execute} requires them before it forks the mutants (#217).
|
|
1342
|
+
#
|
|
1343
|
+
# @api private
|
|
1344
|
+
# @return [Array<String>] absolute paths.
|
|
1345
|
+
def abs_preload_paths = abs_source_paths + @require_paths.map { |p| absolute(p) }
|
|
1346
|
+
|
|
1347
|
+
# Returns absolute load paths.
|
|
1348
|
+
#
|
|
1349
|
+
# @return [Array<String>] absolute load paths.
|
|
1350
|
+
def abs_load_paths = @load_paths.map { |p| absolute(p) }
|
|
1351
|
+
|
|
1352
|
+
# Relativizes a path against the project root (see {ProjectPath.relative}).
|
|
1353
|
+
#
|
|
1354
|
+
# @api private
|
|
1355
|
+
# @param path [String] path to relativize.
|
|
1356
|
+
# @return [String] relative path, or an absolute path when outside the root.
|
|
1357
|
+
def relativize(path) = ProjectPath.relative(path, @project_root)
|
|
1358
|
+
|
|
1359
|
+
# Expands a path relative to the project root (see {ProjectPath.absolute}).
|
|
1360
|
+
#
|
|
1361
|
+
# @api private
|
|
1362
|
+
# @param path [String] path to expand.
|
|
1363
|
+
# @return [String] absolute path.
|
|
1364
|
+
def absolute(path) = ProjectPath.absolute(path, @project_root)
|
|
1365
|
+
end
|
|
1366
|
+
end
|