mutation_tester 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/Gemfile +9 -0
- data/Gemfile.lock +83 -0
- data/LICENSE.txt +19 -0
- data/Rakefile +57 -0
- data/docs/ci.md +158 -0
- data/docs/execution-runners.md +163 -0
- data/docs/json-schema.md +171 -0
- data/docs/mutation-types.md +207 -0
- data/examples/calculator.rb +35 -0
- data/examples/calculator_100_perc_spec.rb +121 -0
- data/examples/calculator_minitest.rb +53 -0
- data/examples/calculator_spec.rb +105 -0
- data/examples/github_actions/ai_mutation_gate.yml +75 -0
- data/examples/github_actions/mutation_test.yml +87 -0
- data/examples/hooks/pre-push +41 -0
- data/examples/run_example.rb +31 -0
- data/examples/run_example_minitest.rb +38 -0
- data/examples/run_example_parallel_minitest.rb +48 -0
- data/examples/run_example_parallel_rspec.rb +48 -0
- data/examples/run_example_rspec.rb +38 -0
- data/examples/spec_helper.rb +21 -0
- data/exe/mutation_test +345 -0
- data/lib/mutation_tester/batch_runner.rb +286 -0
- data/lib/mutation_tester/configuration.rb +105 -0
- data/lib/mutation_tester/core.rb +193 -0
- data/lib/mutation_tester/fork_runner/worker.rb +202 -0
- data/lib/mutation_tester/fork_runner.rb +382 -0
- data/lib/mutation_tester/framework_detector.rb +25 -0
- data/lib/mutation_tester/in_memory_loader.rb +25 -0
- data/lib/mutation_tester/mutation_runner.rb +644 -0
- data/lib/mutation_tester/mutator.rb +874 -0
- data/lib/mutation_tester/progress_display.rb +130 -0
- data/lib/mutation_tester/railtie.rb +7 -0
- data/lib/mutation_tester/rake_task.rb +18 -0
- data/lib/mutation_tester/reporters/base_reporter.rb +154 -0
- data/lib/mutation_tester/reporters/batch_json_reporter.rb +72 -0
- data/lib/mutation_tester/reporters/console_reporter.rb +130 -0
- data/lib/mutation_tester/reporters/html_reporter.rb +693 -0
- data/lib/mutation_tester/reporters/json_reporter.rb +67 -0
- data/lib/mutation_tester/test_command.rb +165 -0
- data/lib/mutation_tester/version.rb +3 -0
- data/lib/mutation_tester.rb +52 -0
- data/lib/tasks/mutation_tester.rake +80 -0
- data/readme.md +925 -0
- metadata +213 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
require 'tmpdir'
|
|
2
|
+
require 'pathname'
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module MutationTester
|
|
6
|
+
class MutationRunner
|
|
7
|
+
PARALLEL_INTERRUPT_LINE = "Parallel execution interrupted, exiting ...\n"
|
|
8
|
+
|
|
9
|
+
class ParallelInterruptFilter
|
|
10
|
+
def initialize(target)
|
|
11
|
+
@target = target
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def write(*args)
|
|
15
|
+
return args.first.bytesize if args.length == 1 && args.first == PARALLEL_INTERRUPT_LINE
|
|
16
|
+
|
|
17
|
+
@target.write(*args)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def respond_to_missing?(name, include_private = false)
|
|
21
|
+
@target.respond_to?(name, include_private)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def method_missing(name, *args, &block)
|
|
25
|
+
@target.send(name, *args, &block)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
private_constant :PARALLEL_INTERRUPT_LINE, :ParallelInterruptFilter
|
|
29
|
+
|
|
30
|
+
def initialize(source_file, spec_file, original_content, config)
|
|
31
|
+
@source_file = File.expand_path(source_file)
|
|
32
|
+
@spec_file = File.expand_path(spec_file)
|
|
33
|
+
@original_content = original_content
|
|
34
|
+
@config = config
|
|
35
|
+
@use_bundle_exec = TestCommand.use_bundle_exec?(@source_file)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.backup_path_for(source_file)
|
|
39
|
+
"#{File.expand_path(source_file)}.mutation_backup"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.recover_in_place_backup(source_file)
|
|
43
|
+
backup = backup_path_for(source_file)
|
|
44
|
+
return false unless File.exist?(backup)
|
|
45
|
+
|
|
46
|
+
File.write(File.expand_path(source_file), File.read(backup))
|
|
47
|
+
File.delete(backup)
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def run(mutations, &progress_callback)
|
|
52
|
+
announce_worker_env_in_memory_opt_out
|
|
53
|
+
if in_memory_first? && @config.parallel_processes > 1
|
|
54
|
+
run_in_memory_parallel(mutations, &progress_callback)
|
|
55
|
+
elsif in_memory_first?
|
|
56
|
+
run_in_memory_series(mutations, &progress_callback)
|
|
57
|
+
elsif @config.parallel_processes == 1
|
|
58
|
+
run_in_place_series(mutations, &progress_callback)
|
|
59
|
+
else
|
|
60
|
+
run_in_shadow_parallel(mutations, &progress_callback)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def in_memory_first?
|
|
65
|
+
return false if @config.worker_env_var
|
|
66
|
+
|
|
67
|
+
%i[in_memory auto].include?(@config.runner)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def run_in_memory_series(mutations, &progress_callback)
|
|
71
|
+
blocker = prepare_in_memory_execution
|
|
72
|
+
return fall_back_to_file_based(blocker, mutations, &progress_callback) if blocker
|
|
73
|
+
|
|
74
|
+
warn '[MutationTester] In-memory execution selected (serial, zero file writes per mutant).'
|
|
75
|
+
announce_load_time_routing(mutations)
|
|
76
|
+
results = []
|
|
77
|
+
begin
|
|
78
|
+
mutations.each_with_index do |mutation, index|
|
|
79
|
+
unless @in_memory_runner&.ready?
|
|
80
|
+
return results + fall_back_to_file_based(
|
|
81
|
+
'the in-memory worker terminated unexpectedly',
|
|
82
|
+
mutations.drop(index), completed: index, &progress_callback
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
result = run_single_mutation(mutation, :in_memory)
|
|
87
|
+
progress_callback.call(mutation, index + 1) if progress_callback
|
|
88
|
+
results << result
|
|
89
|
+
break if stop_early?(result)
|
|
90
|
+
end
|
|
91
|
+
ensure
|
|
92
|
+
shutdown_in_memory_worker
|
|
93
|
+
cleanup_shadow_workspaces
|
|
94
|
+
end
|
|
95
|
+
results
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def run_in_memory_parallel(mutations, &progress_callback)
|
|
99
|
+
blocker = prepare_in_memory_execution
|
|
100
|
+
return fall_back_to_parallel_file_based(blocker, mutations, &progress_callback) if blocker
|
|
101
|
+
|
|
102
|
+
pool = ForkRunner.prepare_in_memory_pool([@config.parallel_processes, mutations.size].min, @in_memory_runner)
|
|
103
|
+
if pool.compact.empty?
|
|
104
|
+
return fall_back_to_parallel_file_based('the preloaded worker pool could not be cloned', mutations, &progress_callback)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
warn "[MutationTester] In-memory execution selected (parallel, #{pool.size} preloaded workers, zero file writes per mutant)."
|
|
108
|
+
announce_load_time_routing(mutations)
|
|
109
|
+
total = mutations.size
|
|
110
|
+
completed_count = 0
|
|
111
|
+
collected = []
|
|
112
|
+
project_root = discoverable_project_root
|
|
113
|
+
reserve_fallback_shadow_root
|
|
114
|
+
|
|
115
|
+
report_progress = lambda do |_item, _index, result|
|
|
116
|
+
completed_count += 1
|
|
117
|
+
collected << result
|
|
118
|
+
if progress_callback && (completed_count.even? || completed_count == total)
|
|
119
|
+
progress_callback.call(nil, completed_count)
|
|
120
|
+
end
|
|
121
|
+
raise Parallel::Break if stop_early?(result)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
mapped = with_parallel_interrupt_silenced do
|
|
125
|
+
Parallel.map(mutations, in_processes: pool.size, finish: report_progress) do |mutation|
|
|
126
|
+
run_single_mutation(mutation, :in_memory, project_root)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
mapped || collected
|
|
130
|
+
ensure
|
|
131
|
+
ForkRunner.shutdown_in_memory_pool
|
|
132
|
+
shutdown_in_memory_worker
|
|
133
|
+
cleanup_shadow_workspaces
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def run_in_shadow_parallel(mutations, &progress_callback)
|
|
137
|
+
total = mutations.size
|
|
138
|
+
completed_count = 0
|
|
139
|
+
collected = []
|
|
140
|
+
project_root = find_project_root
|
|
141
|
+
shadow_run_root
|
|
142
|
+
prepare_worker_preloads(total)
|
|
143
|
+
|
|
144
|
+
report_progress = lambda do |_item, _index, result|
|
|
145
|
+
completed_count += 1
|
|
146
|
+
collected << result
|
|
147
|
+
if progress_callback && (completed_count.even? || completed_count == total)
|
|
148
|
+
progress_callback.call(nil, completed_count)
|
|
149
|
+
end
|
|
150
|
+
raise Parallel::Break if stop_early?(result)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
mapped = with_parallel_interrupt_silenced do
|
|
154
|
+
Parallel.map(mutations, in_processes: @config.parallel_processes, finish: report_progress) do |mutation|
|
|
155
|
+
run_single_mutation(mutation, :shadow, project_root)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
mapped || collected
|
|
159
|
+
ensure
|
|
160
|
+
cleanup_shadow_workspaces
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def run_in_place_series(mutations, &progress_callback)
|
|
164
|
+
write_in_place_backup
|
|
165
|
+
results = []
|
|
166
|
+
mutations.each_with_index do |mutation, index|
|
|
167
|
+
result = run_single_mutation(mutation, :in_place)
|
|
168
|
+
progress_callback.call(mutation, index + 1) if progress_callback
|
|
169
|
+
results << result
|
|
170
|
+
break if stop_early?(result)
|
|
171
|
+
end
|
|
172
|
+
results
|
|
173
|
+
ensure
|
|
174
|
+
restore_and_clear_in_place_backup
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def run_single_mutation(mutation, strategy, project_root = nil)
|
|
178
|
+
{
|
|
179
|
+
id: mutation[:id],
|
|
180
|
+
type: mutation[:type],
|
|
181
|
+
line: mutation[:line],
|
|
182
|
+
file_path: @source_file,
|
|
183
|
+
original: mutation[:original],
|
|
184
|
+
mutated: mutation[:mutated],
|
|
185
|
+
source_line: mutation[:source_line],
|
|
186
|
+
mutated_line: mutation[:mutated_line],
|
|
187
|
+
killed: false,
|
|
188
|
+
timeout: false,
|
|
189
|
+
status: :survived,
|
|
190
|
+
description: mutation[:description]
|
|
191
|
+
}.tap do |result|
|
|
192
|
+
if unparseable?(mutation[:code])
|
|
193
|
+
mark_stillborn(result)
|
|
194
|
+
elsif strategy == :in_memory && mutation[:in_memory_safe] == false
|
|
195
|
+
run_mutation_load_time(mutation, result, project_root)
|
|
196
|
+
elsif strategy == :in_memory
|
|
197
|
+
run_mutation_in_memory(mutation, result, project_root)
|
|
198
|
+
elsif strategy == :in_place
|
|
199
|
+
run_mutation_in_place(mutation, result)
|
|
200
|
+
else
|
|
201
|
+
run_mutation_in_shadow(mutation, result, project_root)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def run_mutation_in_shadow(mutation, result, project_root)
|
|
207
|
+
shadow_root = worker_shadow_root(project_root)
|
|
208
|
+
|
|
209
|
+
relative_source = Pathname.new(@source_file).relative_path_from(Pathname.new(project_root)).to_s
|
|
210
|
+
shadow_source = File.join(shadow_root, relative_source)
|
|
211
|
+
relative_spec = Pathname.new(@spec_file).relative_path_from(Pathname.new(project_root)).to_s
|
|
212
|
+
shadow_spec = File.join(shadow_root, relative_spec)
|
|
213
|
+
|
|
214
|
+
begin
|
|
215
|
+
File.unlink(shadow_source) if File.symlink?(shadow_source)
|
|
216
|
+
File.write(shadow_source, mutation[:code])
|
|
217
|
+
|
|
218
|
+
outcome, phase = run_two_phase(mutation) do |example_filters|
|
|
219
|
+
run_specs_in_shadow(shadow_spec, shadow_root, example_filters: example_filters)
|
|
220
|
+
end
|
|
221
|
+
apply_outcome(result, outcome, phase)
|
|
222
|
+
ensure
|
|
223
|
+
restore_shadow_source(shadow_source, result)
|
|
224
|
+
end
|
|
225
|
+
rescue => e
|
|
226
|
+
mark_error(result, e)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def cleanup_shadow_workspaces
|
|
230
|
+
root = @shadow_run_root
|
|
231
|
+
return unless root
|
|
232
|
+
|
|
233
|
+
begin
|
|
234
|
+
FileUtils.remove_entry(root) if File.directory?(root)
|
|
235
|
+
rescue SystemCallError
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
@shadow_run_root = nil
|
|
239
|
+
@worker_shadow_root = nil
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def shadow_baseline_passes?
|
|
243
|
+
project_root = find_project_root
|
|
244
|
+
|
|
245
|
+
Dir.mktmpdir do |temp_dir|
|
|
246
|
+
shadow_root = File.join(temp_dir, 'shadow')
|
|
247
|
+
FileUtils.mkdir_p(shadow_root)
|
|
248
|
+
shadow_copy_project(project_root, shadow_root)
|
|
249
|
+
|
|
250
|
+
relative_source = Pathname.new(@source_file).relative_path_from(Pathname.new(project_root)).to_s
|
|
251
|
+
shadow_source = File.join(shadow_root, relative_source)
|
|
252
|
+
relative_spec = Pathname.new(@spec_file).relative_path_from(Pathname.new(project_root)).to_s
|
|
253
|
+
shadow_spec = File.join(shadow_root, relative_spec)
|
|
254
|
+
|
|
255
|
+
File.unlink(shadow_source)
|
|
256
|
+
File.write(shadow_source, @original_content)
|
|
257
|
+
|
|
258
|
+
run_specs_in_shadow(shadow_spec, shadow_root).passed?
|
|
259
|
+
end
|
|
260
|
+
rescue => e
|
|
261
|
+
warn("[MutationTester] Shadow sanity check could not prepare the shadow workspace: #{e.message}")
|
|
262
|
+
false
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def shadow_copy_project(source, dest)
|
|
266
|
+
if source == '/' || source.match?(%r{^/(usr|bin|sbin|etc|var|opt)$})
|
|
267
|
+
raise MutationTester::Error, "Refusing to shadow copy system directory: #{source}"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
Dir.glob("#{source}/*", File::FNM_DOTMATCH).each do |path|
|
|
271
|
+
next if ['.', '..', '.git', 'tmp', 'log', 'coverage', 'node_modules'].include?(File.basename(path))
|
|
272
|
+
|
|
273
|
+
basename = File.basename(path)
|
|
274
|
+
target = File.join(dest, basename)
|
|
275
|
+
|
|
276
|
+
if File.directory?(path) && !File.symlink?(path)
|
|
277
|
+
FileUtils.mkdir_p(target)
|
|
278
|
+
shadow_copy_project(path, target)
|
|
279
|
+
elsif File.extname(path) == '.rb'
|
|
280
|
+
FileUtils.copy_file(path, target)
|
|
281
|
+
else
|
|
282
|
+
File.symlink(path, target)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def run_specs_in_shadow(spec_file, working_dir, example_filters: [])
|
|
288
|
+
test_command(spec_file, example_filters: example_filters).run(timeout: @config.timeout, chdir: working_dir)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def discoverable_project_root
|
|
292
|
+
find_project_root
|
|
293
|
+
rescue MutationTester::Error
|
|
294
|
+
nil
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def find_project_root
|
|
298
|
+
current = File.dirname(File.expand_path(@source_file))
|
|
299
|
+
loop do
|
|
300
|
+
return current if File.exist?(File.join(current, 'Gemfile')) || File.exist?(File.join(current, '.git'))
|
|
301
|
+
|
|
302
|
+
parent = File.dirname(current)
|
|
303
|
+
|
|
304
|
+
if parent == current || parent == Dir.home || parent == '/'
|
|
305
|
+
raise MutationTester::Error, "Could not find project root (looking for Gemfile or .git). Stopped at #{parent}"
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
current = parent
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def run_mutation_in_place(mutation, result)
|
|
313
|
+
|
|
314
|
+
File.write(@source_file, mutation[:code])
|
|
315
|
+
|
|
316
|
+
outcome, phase = run_two_phase(mutation) do |example_filters|
|
|
317
|
+
run_specs_in_place(@spec_file, example_filters: example_filters)
|
|
318
|
+
end
|
|
319
|
+
apply_outcome(result, outcome, phase)
|
|
320
|
+
rescue StandardError, Interrupt => e
|
|
321
|
+
mark_error(result, e)
|
|
322
|
+
raise e if e.is_a?(Interrupt)
|
|
323
|
+
ensure
|
|
324
|
+
File.write(@source_file, @original_content)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def run_specs_in_place(spec_file, example_filters: [])
|
|
328
|
+
test_command(spec_file, example_filters: example_filters).run(timeout: @config.timeout)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def run_mutation_in_memory(mutation, result, project_root = nil)
|
|
332
|
+
runner = active_in_memory_runner
|
|
333
|
+
return in_memory_worker_fallback(mutation, result, project_root, 'the in-memory worker is unavailable') unless runner&.ready?
|
|
334
|
+
|
|
335
|
+
outcome = runner.execute_in_memory(
|
|
336
|
+
source: mutation[:code],
|
|
337
|
+
path: @source_file,
|
|
338
|
+
timeout: @config.timeout,
|
|
339
|
+
chdir: Dir.pwd
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
if outcome.status == 'error'
|
|
343
|
+
in_memory_apply_fallback(mutation, result, project_root, outcome.message || 'in-memory application failed')
|
|
344
|
+
else
|
|
345
|
+
apply_outcome(result, TestCommand::Result.new(outcome.status == 'pass', outcome.status == 'timeout'))
|
|
346
|
+
end
|
|
347
|
+
rescue MutationTester::Error => e
|
|
348
|
+
return in_memory_worker_fallback(mutation, result, project_root, e.message) if project_root
|
|
349
|
+
|
|
350
|
+
mark_error(result, e)
|
|
351
|
+
rescue StandardError => e
|
|
352
|
+
mark_error(result, e)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def run_two_phase(mutation)
|
|
356
|
+
filters = subset_filters(mutation)
|
|
357
|
+
unless filters.empty?
|
|
358
|
+
subset_outcome = yield(filters)
|
|
359
|
+
return [subset_outcome, :subset] unless subset_outcome.passed?
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
[yield([]), :full]
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def subset_filters(mutation)
|
|
366
|
+
return [] unless @config.test_selection
|
|
367
|
+
return [] unless detect_test_framework(@spec_file) == :rspec
|
|
368
|
+
|
|
369
|
+
method_name = mutation[:method_name].to_s
|
|
370
|
+
return [] if method_name.empty?
|
|
371
|
+
|
|
372
|
+
content = spec_content
|
|
373
|
+
["##{method_name}", ".#{method_name}"].select do |token|
|
|
374
|
+
content.match?(/['"]#{Regexp.escape(token)}/)
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def detect_test_framework(spec_path)
|
|
379
|
+
FrameworkDetector.detect(spec_path)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
private
|
|
383
|
+
|
|
384
|
+
def with_parallel_interrupt_silenced
|
|
385
|
+
previous_stderr = $stderr
|
|
386
|
+
$stderr = ParallelInterruptFilter.new(previous_stderr)
|
|
387
|
+
yield
|
|
388
|
+
ensure
|
|
389
|
+
$stderr = previous_stderr
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def prepare_in_memory_execution
|
|
393
|
+
return 'the in-memory runner supports RSpec suites only' unless detect_test_framework(@spec_file) == :rspec
|
|
394
|
+
return 'Process.fork is not supported on this platform' unless ForkRunner.available?
|
|
395
|
+
if InMemoryLoader.load_time_defined_guard?(@original_content)
|
|
396
|
+
return 'the source file uses defined? at load time, so redefinition would silently skip the guarded code'
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
runner = ForkRunner.new(use_bundle_exec: @use_bundle_exec)
|
|
400
|
+
unless runner.ready?
|
|
401
|
+
runner.shutdown
|
|
402
|
+
return 'the in-memory worker failed to preload the environment'
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
preloaded, message = runner.preload(@spec_file, chdir: Dir.pwd)
|
|
406
|
+
unless preloaded
|
|
407
|
+
runner.shutdown
|
|
408
|
+
return "the spec file could not be preloaded (#{message})"
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
probe = probe_in_memory_application(runner)
|
|
412
|
+
return probe if probe
|
|
413
|
+
|
|
414
|
+
@in_memory_runner = runner
|
|
415
|
+
nil
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def probe_in_memory_application(runner)
|
|
419
|
+
outcome = runner.execute_in_memory(
|
|
420
|
+
source: @original_content,
|
|
421
|
+
path: @source_file,
|
|
422
|
+
timeout: @config.timeout,
|
|
423
|
+
chdir: Dir.pwd
|
|
424
|
+
)
|
|
425
|
+
return nil if outcome.status == 'pass'
|
|
426
|
+
|
|
427
|
+
runner.shutdown
|
|
428
|
+
case outcome.status
|
|
429
|
+
when 'error'
|
|
430
|
+
"re-applying the unmutated source in memory failed (#{outcome.message})"
|
|
431
|
+
when 'timeout'
|
|
432
|
+
're-applying the unmutated source in memory exceeded the mutant deadline'
|
|
433
|
+
else
|
|
434
|
+
'the test suite fails after the unmutated source is re-applied in memory (load-time side effects?)'
|
|
435
|
+
end
|
|
436
|
+
rescue MutationTester::Error => e
|
|
437
|
+
e.message
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def fall_back_to_file_based(reason, mutations, completed: 0, &progress_callback)
|
|
441
|
+
announce_file_based_fallback(reason)
|
|
442
|
+
offset_callback = progress_callback && lambda do |mutation, index|
|
|
443
|
+
progress_callback.call(mutation, completed + index)
|
|
444
|
+
end
|
|
445
|
+
run_in_place_series(mutations, &offset_callback)
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def fall_back_to_parallel_file_based(reason, mutations, &progress_callback)
|
|
449
|
+
announce_file_based_fallback(reason)
|
|
450
|
+
return run_in_shadow_parallel(mutations, &progress_callback) if shadow_baseline_passes?
|
|
451
|
+
|
|
452
|
+
warn '[MutationTester] The unmutated source fails inside the shadow workspace; finishing serially in place instead.'
|
|
453
|
+
run_in_place_series(mutations, &progress_callback)
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def announce_file_based_fallback(reason)
|
|
457
|
+
label = test_command(@spec_file).fork_execution? ? 'fork' : 'spawn'
|
|
458
|
+
warn "[MutationTester] In-memory execution is unavailable: #{reason}. Falling back to file-based execution (#{label})."
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def announce_worker_env_in_memory_opt_out
|
|
462
|
+
return unless @config.worker_env_var
|
|
463
|
+
return unless %i[in_memory auto].include?(@config.runner)
|
|
464
|
+
|
|
465
|
+
warn "[MutationTester] --worker-env #{@config.worker_env_var} is set, so the in-memory runner is skipped (its clones share one preloaded database connection); using the fork runner for per-worker database isolation."
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def run_mutation_load_time(mutation, result, project_root)
|
|
469
|
+
root = project_root || discoverable_project_root
|
|
470
|
+
return mark_error(result, MutationTester::Error.new('a load-time mutant needs a project root for file-based execution')) unless root
|
|
471
|
+
|
|
472
|
+
run_mutation_in_shadow(mutation, result, root)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def announce_load_time_routing(mutations)
|
|
476
|
+
return unless mutations.any? { |mutation| mutation[:in_memory_safe] == false }
|
|
477
|
+
|
|
478
|
+
warn '[MutationTester] Some mutants affect load-time code (constants, class macros, included do); those run file-based so the in-memory score matches a full fork run.'
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def in_memory_apply_fallback(mutation, result, project_root, reason)
|
|
482
|
+
root = project_root || discoverable_project_root
|
|
483
|
+
return mark_error(result, MutationTester::Error.new(reason)) unless root
|
|
484
|
+
|
|
485
|
+
unless @in_memory_apply_fallback_announced
|
|
486
|
+
@in_memory_apply_fallback_announced = true
|
|
487
|
+
warn "[MutationTester] A mutant could not be applied in memory (#{reason}); deciding each such mutant file-based."
|
|
488
|
+
end
|
|
489
|
+
run_mutation_in_shadow(mutation, result, root)
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def in_memory_worker_fallback(mutation, result, project_root, reason)
|
|
493
|
+
return mark_error(result, MutationTester::Error.new(reason)) unless project_root
|
|
494
|
+
|
|
495
|
+
unless @in_memory_fallback_announced
|
|
496
|
+
@in_memory_fallback_announced = true
|
|
497
|
+
warn "[MutationTester] An in-memory worker became unavailable (#{reason}); finishing its share of mutants file-based."
|
|
498
|
+
end
|
|
499
|
+
run_mutation_in_shadow(mutation, result, project_root)
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def active_in_memory_runner
|
|
503
|
+
return @in_memory_runner unless ForkRunner.in_memory_pool_prepared?
|
|
504
|
+
|
|
505
|
+
ForkRunner.checkout_in_memory
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def reserve_fallback_shadow_root
|
|
509
|
+
@shadow_run_root ||= File.join(Dir.tmpdir, "mutation_tester_shadow-#{Process.pid}-#{SecureRandom.hex(8)}")
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def shutdown_in_memory_worker
|
|
513
|
+
runner = @in_memory_runner
|
|
514
|
+
@in_memory_runner = nil
|
|
515
|
+
runner&.shutdown
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def prepare_worker_preloads(total)
|
|
519
|
+
return unless test_command(@spec_file).fork_execution?
|
|
520
|
+
|
|
521
|
+
ForkRunner.prepare_pool(
|
|
522
|
+
[@config.parallel_processes, total].min,
|
|
523
|
+
use_bundle_exec: @use_bundle_exec,
|
|
524
|
+
env_for: worker_env_for
|
|
525
|
+
)
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def worker_env_for
|
|
529
|
+
return nil unless @config.worker_env_var
|
|
530
|
+
|
|
531
|
+
->(index) { @config.worker_env_assignment(index) }
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def shadow_run_root
|
|
535
|
+
@shadow_run_root ||= Dir.mktmpdir('mutation_tester_shadow')
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def worker_shadow_root(project_root)
|
|
539
|
+
if @worker_shadow_pid != Process.pid
|
|
540
|
+
@worker_shadow_pid = Process.pid
|
|
541
|
+
@worker_shadow_root = nil
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
@worker_shadow_root ||= build_worker_workspace(project_root)
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def build_worker_workspace(project_root)
|
|
548
|
+
@worker_workspace_serial = @worker_workspace_serial.to_i + 1
|
|
549
|
+
root = File.join(shadow_run_root, "worker-#{Process.pid}-#{@worker_workspace_serial}")
|
|
550
|
+
FileUtils.mkdir_p(root)
|
|
551
|
+
shadow_copy_project(project_root, root)
|
|
552
|
+
root
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def restore_shadow_source(shadow_source, result)
|
|
556
|
+
File.write(shadow_source, @original_content)
|
|
557
|
+
rescue StandardError => e
|
|
558
|
+
discard_worker_workspace
|
|
559
|
+
mark_error(result, e)
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def discard_worker_workspace
|
|
563
|
+
root = @worker_shadow_root
|
|
564
|
+
@worker_shadow_root = nil
|
|
565
|
+
return unless root && File.directory?(root)
|
|
566
|
+
|
|
567
|
+
FileUtils.remove_entry(root)
|
|
568
|
+
rescue SystemCallError
|
|
569
|
+
nil
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def stop_early?(result)
|
|
573
|
+
@config.fail_fast && result[:status] == :survived
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
def backup_path
|
|
577
|
+
self.class.backup_path_for(@source_file)
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def write_in_place_backup
|
|
581
|
+
File.write(backup_path, @original_content)
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
def restore_and_clear_in_place_backup
|
|
585
|
+
return unless File.exist?(backup_path)
|
|
586
|
+
|
|
587
|
+
File.write(@source_file, File.read(backup_path))
|
|
588
|
+
File.delete(backup_path)
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def apply_outcome(result, outcome, phase = nil)
|
|
592
|
+
if outcome.timed_out?
|
|
593
|
+
result[:killed] = true
|
|
594
|
+
result[:timeout] = true
|
|
595
|
+
result[:status] = :timeout
|
|
596
|
+
elsif outcome.passed?
|
|
597
|
+
result[:status] = :survived
|
|
598
|
+
else
|
|
599
|
+
result[:killed] = true
|
|
600
|
+
result[:status] = :killed
|
|
601
|
+
end
|
|
602
|
+
result[:kill_phase] = phase if result[:killed] && phase
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
def mark_stillborn(result)
|
|
606
|
+
result[:status] = :stillborn
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
def mark_error(result, error)
|
|
610
|
+
result[:killed] = false
|
|
611
|
+
result[:timeout] = false
|
|
612
|
+
result[:status] = :error
|
|
613
|
+
result[:description] = "Error: #{error.message}"
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def unparseable?(code)
|
|
617
|
+
return false if code.nil?
|
|
618
|
+
|
|
619
|
+
buffer = Parser::Source::Buffer.new('(mutant)')
|
|
620
|
+
buffer.source = code
|
|
621
|
+
parser = Parser::CurrentRuby.new
|
|
622
|
+
parser.diagnostics.all_errors_are_fatal = true
|
|
623
|
+
parser.diagnostics.ignore_warnings = true
|
|
624
|
+
parser.parse(buffer)
|
|
625
|
+
false
|
|
626
|
+
rescue Parser::SyntaxError
|
|
627
|
+
true
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def spec_content
|
|
631
|
+
@spec_content ||= File.exist?(@spec_file) ? File.read(@spec_file) : ''
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
def test_command(spec_file, example_filters: [])
|
|
635
|
+
TestCommand.new(
|
|
636
|
+
File.expand_path(spec_file),
|
|
637
|
+
use_bundle_exec: @use_bundle_exec,
|
|
638
|
+
runner: @config.runner,
|
|
639
|
+
example_filters: example_filters,
|
|
640
|
+
worker_env_var: @config.worker_env_var
|
|
641
|
+
)
|
|
642
|
+
end
|
|
643
|
+
end
|
|
644
|
+
end
|