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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +13 -0
  3. data/Gemfile +9 -0
  4. data/Gemfile.lock +83 -0
  5. data/LICENSE.txt +19 -0
  6. data/Rakefile +57 -0
  7. data/docs/ci.md +158 -0
  8. data/docs/execution-runners.md +163 -0
  9. data/docs/json-schema.md +171 -0
  10. data/docs/mutation-types.md +207 -0
  11. data/examples/calculator.rb +35 -0
  12. data/examples/calculator_100_perc_spec.rb +121 -0
  13. data/examples/calculator_minitest.rb +53 -0
  14. data/examples/calculator_spec.rb +105 -0
  15. data/examples/github_actions/ai_mutation_gate.yml +75 -0
  16. data/examples/github_actions/mutation_test.yml +87 -0
  17. data/examples/hooks/pre-push +41 -0
  18. data/examples/run_example.rb +31 -0
  19. data/examples/run_example_minitest.rb +38 -0
  20. data/examples/run_example_parallel_minitest.rb +48 -0
  21. data/examples/run_example_parallel_rspec.rb +48 -0
  22. data/examples/run_example_rspec.rb +38 -0
  23. data/examples/spec_helper.rb +21 -0
  24. data/exe/mutation_test +345 -0
  25. data/lib/mutation_tester/batch_runner.rb +286 -0
  26. data/lib/mutation_tester/configuration.rb +105 -0
  27. data/lib/mutation_tester/core.rb +193 -0
  28. data/lib/mutation_tester/fork_runner/worker.rb +202 -0
  29. data/lib/mutation_tester/fork_runner.rb +382 -0
  30. data/lib/mutation_tester/framework_detector.rb +25 -0
  31. data/lib/mutation_tester/in_memory_loader.rb +25 -0
  32. data/lib/mutation_tester/mutation_runner.rb +644 -0
  33. data/lib/mutation_tester/mutator.rb +874 -0
  34. data/lib/mutation_tester/progress_display.rb +130 -0
  35. data/lib/mutation_tester/railtie.rb +7 -0
  36. data/lib/mutation_tester/rake_task.rb +18 -0
  37. data/lib/mutation_tester/reporters/base_reporter.rb +154 -0
  38. data/lib/mutation_tester/reporters/batch_json_reporter.rb +72 -0
  39. data/lib/mutation_tester/reporters/console_reporter.rb +130 -0
  40. data/lib/mutation_tester/reporters/html_reporter.rb +693 -0
  41. data/lib/mutation_tester/reporters/json_reporter.rb +67 -0
  42. data/lib/mutation_tester/test_command.rb +165 -0
  43. data/lib/mutation_tester/version.rb +3 -0
  44. data/lib/mutation_tester.rb +52 -0
  45. data/lib/tasks/mutation_tester.rake +80 -0
  46. data/readme.md +925 -0
  47. 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