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,382 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'tempfile'
|
|
3
|
+
require 'tmpdir'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module MutationTester
|
|
7
|
+
class ForkRunner
|
|
8
|
+
InMemoryOutcome = Struct.new(:status, :message)
|
|
9
|
+
|
|
10
|
+
WORKER_PATH = File.expand_path('fork_runner/worker.rb', __dir__)
|
|
11
|
+
IN_MEMORY_POOL_KEY = :in_memory
|
|
12
|
+
BOOT_TIMEOUT = 60
|
|
13
|
+
CLONE_TIMEOUT = 10
|
|
14
|
+
RESPONSE_GRACE = 15
|
|
15
|
+
SHUTDOWN_GRACE = 2
|
|
16
|
+
POLL_INTERVAL = 0.05
|
|
17
|
+
HANDSHAKE_POLL = 0.005
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def available?
|
|
21
|
+
Process.respond_to?(:fork)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def acquire(use_bundle_exec:)
|
|
25
|
+
return nil unless available?
|
|
26
|
+
|
|
27
|
+
key = [Process.pid, use_bundle_exec]
|
|
28
|
+
return registry[key] if registry.key?(key)
|
|
29
|
+
|
|
30
|
+
registry[key] = checkout_pooled(use_bundle_exec) || boot(use_bundle_exec)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def prepare_pool(count, use_bundle_exec:, env_for: nil)
|
|
34
|
+
return unless available?
|
|
35
|
+
|
|
36
|
+
primary = acquire(use_bundle_exec: use_bundle_exec)
|
|
37
|
+
return unless primary
|
|
38
|
+
|
|
39
|
+
refill_pool(use_bundle_exec, count, primary, env_for: env_for)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def prepare_in_memory_pool(count, primary)
|
|
43
|
+
return [] unless available? && primary&.ready?
|
|
44
|
+
|
|
45
|
+
refill_pool(IN_MEMORY_POOL_KEY, count, primary)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def in_memory_pool_prepared?
|
|
49
|
+
pool.key?(IN_MEMORY_POOL_KEY)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def checkout_in_memory
|
|
53
|
+
number = parallel_worker_number
|
|
54
|
+
return nil unless number
|
|
55
|
+
|
|
56
|
+
entry = pool[IN_MEMORY_POOL_KEY]
|
|
57
|
+
entry && entry[:runners][number]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def shutdown_in_memory_pool
|
|
61
|
+
entry = pool[IN_MEMORY_POOL_KEY]
|
|
62
|
+
return unless entry && entry[:owner] == Process.pid
|
|
63
|
+
|
|
64
|
+
pool.delete(IN_MEMORY_POOL_KEY)
|
|
65
|
+
entry[:runners].compact.map { |runner| Thread.new { runner.shutdown } }.each(&:join)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def shutdown_all
|
|
69
|
+
owned = pool.each_value.select { |entry| entry[:owner] == Process.pid }
|
|
70
|
+
owned.flat_map { |entry| entry[:runners] }.compact
|
|
71
|
+
.map { |runner| Thread.new { runner.shutdown } }
|
|
72
|
+
.each(&:join)
|
|
73
|
+
pool.delete_if { |_, entry| entry[:owner] == Process.pid }
|
|
74
|
+
registry.each do |(pid, _), runner|
|
|
75
|
+
runner&.shutdown if pid == Process.pid
|
|
76
|
+
end
|
|
77
|
+
registry.delete_if { |(pid, _), _| pid == Process.pid }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def discard(runner)
|
|
81
|
+
registry.delete_if { |_, value| value.equal?(runner) }
|
|
82
|
+
pool.each_value do |entry|
|
|
83
|
+
entry[:runners].map! { |value| value.equal?(runner) ? nil : value }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def registry
|
|
88
|
+
@registry ||= {}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def pool
|
|
92
|
+
@pool ||= {}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def attached(pid, job_writer, event_reader)
|
|
96
|
+
runner = allocate
|
|
97
|
+
runner.send(:attach_endpoints, pid, job_writer, event_reader, CLONE_TIMEOUT, tolerate_eof: true)
|
|
98
|
+
runner
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def checkout_pooled(use_bundle_exec)
|
|
104
|
+
number = parallel_worker_number
|
|
105
|
+
return nil unless number
|
|
106
|
+
|
|
107
|
+
entry = pool[use_bundle_exec]
|
|
108
|
+
entry && entry[:runners][number]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def parallel_worker_number
|
|
112
|
+
Parallel.worker_number if defined?(Parallel) && Parallel.respond_to?(:worker_number)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def refill_pool(key, count, primary, env_for: nil)
|
|
116
|
+
entry = (pool[key] ||= { owner: Process.pid, runners: [] })
|
|
117
|
+
entry[:runners] = entry[:runners].each_with_index.map do |runner, index|
|
|
118
|
+
runner&.ready? ? runner : primary.fork_clone(env: env_for&.call(index))
|
|
119
|
+
end
|
|
120
|
+
entry[:runners].size.upto(count - 1) do |index|
|
|
121
|
+
entry[:runners] << primary.fork_clone(env: env_for&.call(index))
|
|
122
|
+
end
|
|
123
|
+
entry[:runners]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def boot(use_bundle_exec)
|
|
127
|
+
runner = new(use_bundle_exec: use_bundle_exec)
|
|
128
|
+
return runner if runner.ready?
|
|
129
|
+
|
|
130
|
+
runner.shutdown
|
|
131
|
+
warn '[MutationTester] The fork runner worker failed to preload the environment; falling back to spawn execution.'
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def initialize(use_bundle_exec:)
|
|
137
|
+
argv = ['ruby', WORKER_PATH]
|
|
138
|
+
argv = ['bundle', 'exec', *argv] if use_bundle_exec
|
|
139
|
+
|
|
140
|
+
job_reader, job_writer = IO.pipe
|
|
141
|
+
event_reader, event_writer = IO.pipe
|
|
142
|
+
pid = Process.spawn(*argv, pgroup: true, in: job_reader, out: event_writer, err: File::NULL)
|
|
143
|
+
job_reader.close
|
|
144
|
+
event_writer.close
|
|
145
|
+
attach_endpoints(pid, job_writer, event_reader, BOOT_TIMEOUT)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def ready?
|
|
149
|
+
@ready
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def execute(spec_file, timeout: nil, chdir: nil, capture: false, args: [])
|
|
153
|
+
log = capture ? Tempfile.new(['mutation_tester_fork', '.log']) : nil
|
|
154
|
+
job = { spec: spec_file, timeout: timeout, chdir: chdir, log: log&.path, args: args }
|
|
155
|
+
@job_writer.puts(JSON.generate(job))
|
|
156
|
+
status = await_result(timeout)['status']
|
|
157
|
+
result = TestCommand::Result.new(status == 'pass', status == 'timeout')
|
|
158
|
+
result.output = File.read(log.path) if log
|
|
159
|
+
result
|
|
160
|
+
rescue Errno::EPIPE
|
|
161
|
+
fail_worker
|
|
162
|
+
ensure
|
|
163
|
+
log&.close
|
|
164
|
+
log&.unlink
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def preload(spec_file, chdir: nil)
|
|
168
|
+
@job_writer.puts(JSON.generate(preload: { spec: spec_file, chdir: chdir }))
|
|
169
|
+
event = read_event(monotonic_time + BOOT_TIMEOUT)
|
|
170
|
+
return [true, nil] if event.is_a?(Hash) && event['event'] == 'preloaded' && event['status'] == 'ok'
|
|
171
|
+
|
|
172
|
+
message = event.is_a?(Hash) && event['message'] ? event['message'] : 'the worker did not confirm the spec preload'
|
|
173
|
+
[false, message]
|
|
174
|
+
rescue Errno::EPIPE, IOError
|
|
175
|
+
[false, 'the fork runner worker terminated unexpectedly']
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def execute_in_memory(source:, path:, timeout: nil, chdir: nil)
|
|
179
|
+
job = { in_memory: { source: source, path: path }, timeout: timeout, chdir: chdir }
|
|
180
|
+
@job_writer.puts(JSON.generate(job))
|
|
181
|
+
event = await_result(timeout)
|
|
182
|
+
InMemoryOutcome.new(event['status'], event['message'])
|
|
183
|
+
rescue Errno::EPIPE
|
|
184
|
+
fail_worker
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def fork_clone(env: nil)
|
|
188
|
+
return nil unless ready?
|
|
189
|
+
return nil unless File.respond_to?(:mkfifo)
|
|
190
|
+
|
|
191
|
+
dir = Dir.mktmpdir('mutation_tester_clone')
|
|
192
|
+
job_path = File.join(dir, 'job')
|
|
193
|
+
events_path = File.join(dir, 'events')
|
|
194
|
+
File.mkfifo(job_path)
|
|
195
|
+
File.mkfifo(events_path)
|
|
196
|
+
|
|
197
|
+
clone_request = { 'job' => job_path, 'events' => events_path }
|
|
198
|
+
clone_request['env'] = env if env
|
|
199
|
+
@job_writer.puts(JSON.generate('clone' => clone_request))
|
|
200
|
+
event = read_event(monotonic_time + CLONE_TIMEOUT)
|
|
201
|
+
return nil unless event.is_a?(Hash) && event['event'] == 'cloned'
|
|
202
|
+
|
|
203
|
+
adopt_clone(event['pid'], job_path, events_path)
|
|
204
|
+
rescue SystemCallError, IOError
|
|
205
|
+
nil
|
|
206
|
+
ensure
|
|
207
|
+
FileUtils.remove_entry(dir) if dir
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def shutdown
|
|
211
|
+
@shutdown_mutex.synchronize do
|
|
212
|
+
pid = @pid
|
|
213
|
+
next unless pid
|
|
214
|
+
|
|
215
|
+
@pid = nil
|
|
216
|
+
kill_group(@current_child) if @current_child
|
|
217
|
+
@current_child = nil
|
|
218
|
+
begin
|
|
219
|
+
Process.kill('TERM', pid)
|
|
220
|
+
rescue Errno::ESRCH
|
|
221
|
+
end
|
|
222
|
+
kill_group(pid) unless reaped_within(pid, SHUTDOWN_GRACE)
|
|
223
|
+
close_pipes
|
|
224
|
+
@ready = false
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
private
|
|
229
|
+
|
|
230
|
+
def attach_endpoints(pid, job_writer, event_reader, ready_timeout, tolerate_eof: false)
|
|
231
|
+
@pid = pid
|
|
232
|
+
@job_writer = job_writer
|
|
233
|
+
@job_writer.sync = true
|
|
234
|
+
@event_reader = event_reader
|
|
235
|
+
@current_child = nil
|
|
236
|
+
@shutdown_mutex = Mutex.new
|
|
237
|
+
@ready = await_ready(monotonic_time + ready_timeout, tolerate_eof)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def await_ready(deadline, tolerate_eof)
|
|
241
|
+
loop do
|
|
242
|
+
event = read_event(deadline)
|
|
243
|
+
return false if event == :deadline
|
|
244
|
+
|
|
245
|
+
if event.nil?
|
|
246
|
+
return false unless tolerate_eof && process_alive?(@pid)
|
|
247
|
+
return false if monotonic_time >= deadline
|
|
248
|
+
|
|
249
|
+
sleep(HANDSHAKE_POLL)
|
|
250
|
+
next
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
return event['event'] == 'ready'
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def adopt_clone(pid, job_path, events_path)
|
|
258
|
+
event_reader = File.open(events_path, File::RDONLY | File::NONBLOCK)
|
|
259
|
+
job_writer = open_fifo_writer(job_path, monotonic_time + CLONE_TIMEOUT)
|
|
260
|
+
|
|
261
|
+
unless job_writer
|
|
262
|
+
event_reader.close
|
|
263
|
+
kill_group(pid)
|
|
264
|
+
return nil
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
clone = self.class.attached(pid, job_writer, event_reader)
|
|
268
|
+
return clone if clone.ready?
|
|
269
|
+
|
|
270
|
+
clone.shutdown
|
|
271
|
+
nil
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def open_fifo_writer(path, deadline)
|
|
275
|
+
loop do
|
|
276
|
+
return File.open(path, File::WRONLY | File::NONBLOCK)
|
|
277
|
+
rescue Errno::ENXIO
|
|
278
|
+
return nil if monotonic_time >= deadline
|
|
279
|
+
|
|
280
|
+
sleep(HANDSHAKE_POLL)
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def await_result(timeout)
|
|
285
|
+
deadline = timeout ? monotonic_time + timeout + RESPONSE_GRACE : nil
|
|
286
|
+
|
|
287
|
+
loop do
|
|
288
|
+
event = read_event(deadline)
|
|
289
|
+
return handle_wedged_worker if event == :deadline
|
|
290
|
+
|
|
291
|
+
fail_worker if event.nil?
|
|
292
|
+
|
|
293
|
+
case event['event']
|
|
294
|
+
when 'started'
|
|
295
|
+
@current_child = event['pid']
|
|
296
|
+
when 'result'
|
|
297
|
+
@current_child = nil
|
|
298
|
+
return event
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def handle_wedged_worker
|
|
304
|
+
shutdown
|
|
305
|
+
self.class.discard(self)
|
|
306
|
+
{ 'status' => 'timeout' }
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def fail_worker
|
|
310
|
+
shutdown
|
|
311
|
+
self.class.discard(self)
|
|
312
|
+
raise MutationTester::Error, 'The fork runner worker terminated unexpectedly'
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def read_event(deadline)
|
|
316
|
+
loop do
|
|
317
|
+
wait = deadline ? deadline - monotonic_time : nil
|
|
318
|
+
return :deadline if wait && wait <= 0
|
|
319
|
+
|
|
320
|
+
ready = IO.select([@event_reader], nil, nil, wait)
|
|
321
|
+
next unless ready
|
|
322
|
+
|
|
323
|
+
line = @event_reader.gets
|
|
324
|
+
return nil if line.nil?
|
|
325
|
+
|
|
326
|
+
return JSON.parse(line)
|
|
327
|
+
end
|
|
328
|
+
rescue JSON::ParserError
|
|
329
|
+
nil
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def reaped_within(pid, grace)
|
|
333
|
+
deadline = monotonic_time + grace
|
|
334
|
+
loop do
|
|
335
|
+
return true if Process.waitpid(pid, Process::WNOHANG)
|
|
336
|
+
return false if monotonic_time >= deadline
|
|
337
|
+
|
|
338
|
+
sleep(POLL_INTERVAL)
|
|
339
|
+
end
|
|
340
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
341
|
+
vanished_within(pid, deadline)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def vanished_within(pid, deadline)
|
|
345
|
+
loop do
|
|
346
|
+
return true unless process_alive?(pid)
|
|
347
|
+
return false if monotonic_time >= deadline
|
|
348
|
+
|
|
349
|
+
sleep(HANDSHAKE_POLL)
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def process_alive?(pid)
|
|
354
|
+
Process.kill(0, pid)
|
|
355
|
+
true
|
|
356
|
+
rescue Errno::ESRCH
|
|
357
|
+
false
|
|
358
|
+
rescue Errno::EPERM
|
|
359
|
+
true
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def kill_group(pid)
|
|
363
|
+
Process.kill('KILL', -pid)
|
|
364
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
365
|
+
ensure
|
|
366
|
+
begin
|
|
367
|
+
Process.waitpid(pid)
|
|
368
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def close_pipes
|
|
373
|
+
[@job_writer, @event_reader].each do |io|
|
|
374
|
+
io.close unless io.nil? || io.closed?
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def monotonic_time
|
|
379
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module MutationTester
|
|
2
|
+
module FrameworkDetector
|
|
3
|
+
MINITEST_REQUIRE = /require\s+['"]minitest/.freeze
|
|
4
|
+
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def detect(spec_file)
|
|
8
|
+
basename = File.basename(spec_file)
|
|
9
|
+
|
|
10
|
+
return :rspec if basename.end_with?('_spec.rb', '.spec.rb')
|
|
11
|
+
return :minitest if basename.end_with?('_test.rb') || basename.start_with?('test_')
|
|
12
|
+
return :minitest if minitest_content?(spec_file)
|
|
13
|
+
|
|
14
|
+
:rspec
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def minitest_content?(spec_file)
|
|
18
|
+
return false unless File.exist?(spec_file)
|
|
19
|
+
|
|
20
|
+
File.read(spec_file).match?(MINITEST_REQUIRE)
|
|
21
|
+
rescue StandardError
|
|
22
|
+
false
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module MutationTester
|
|
2
|
+
module InMemoryLoader
|
|
3
|
+
def self.apply(source, path)
|
|
4
|
+
previous = $VERBOSE
|
|
5
|
+
$VERBOSE = nil
|
|
6
|
+
eval(source, TOPLEVEL_BINDING, path)
|
|
7
|
+
ensure
|
|
8
|
+
$VERBOSE = previous
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.load_time_defined_guard?(source)
|
|
12
|
+
load_time_defined?(Parser::CurrentRuby.parse(source))
|
|
13
|
+
rescue Parser::SyntaxError
|
|
14
|
+
false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.load_time_defined?(node)
|
|
18
|
+
return false unless node.is_a?(Parser::AST::Node)
|
|
19
|
+
return true if node.type == :defined?
|
|
20
|
+
return false if %i[def defs].include?(node.type)
|
|
21
|
+
|
|
22
|
+
node.children.any? { |child| load_time_defined?(child) }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|