henitai 0.1.10 → 0.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 +4 -4
- data/CHANGELOG.md +68 -0
- data/README.md +18 -4
- data/lib/henitai/cli.rb +81 -3
- data/lib/henitai/configuration.rb +24 -11
- data/lib/henitai/coverage_bootstrapper.rb +24 -24
- data/lib/henitai/execution_engine.rb +3 -9
- data/lib/henitai/git_diff_analyzer.rb +34 -0
- data/lib/henitai/integration/rspec_process_runner.rb +66 -13
- data/lib/henitai/integration.rb +386 -38
- data/lib/henitai/mutant/activator.rb +14 -2
- data/lib/henitai/mutant.rb +13 -2
- data/lib/henitai/mutant_generator.rb +21 -2
- data/lib/henitai/mutant_history_store.rb +7 -22
- data/lib/henitai/mutant_identity.rb +34 -0
- data/lib/henitai/parallel_execution_runner.rb +29 -11
- data/lib/henitai/process_wakeup.rb +49 -0
- data/lib/henitai/process_worker_runner.rb +434 -0
- data/lib/henitai/reporter.rb +76 -3
- data/lib/henitai/result.rb +39 -8
- data/lib/henitai/runner.rb +203 -14
- data/lib/henitai/scenario_execution_result.rb +16 -3
- data/lib/henitai/static_filter.rb +10 -3
- data/lib/henitai/survivor_activation_cache.rb +81 -0
- data/lib/henitai/survivor_loader.rb +140 -0
- data/lib/henitai/survivor_selector.rb +36 -0
- data/lib/henitai/survivor_test_filter.rb +72 -0
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +8 -0
- data/sig/henitai.rbs +200 -9
- metadata +22 -1
data/lib/henitai/integration.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "stringio"
|
|
5
|
+
require_relative "process_wakeup"
|
|
4
6
|
require_relative "integration/rspec_process_runner"
|
|
5
7
|
|
|
6
8
|
module Henitai
|
|
@@ -108,6 +110,204 @@ module Henitai
|
|
|
108
110
|
end
|
|
109
111
|
end
|
|
110
112
|
|
|
113
|
+
# Shared debug helpers for child-run diagnostics.
|
|
114
|
+
# Debug helpers are intentionally grouped here so the child-run diagnostics
|
|
115
|
+
# stay isolated from the main integration flow.
|
|
116
|
+
# rubocop:disable Metrics/ModuleLength
|
|
117
|
+
module ChildDebugSupport
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def run_rspec_runner(test_files)
|
|
121
|
+
debug_child_puts("[henitai-debug-child] build_rspec_runner_start")
|
|
122
|
+
runner = build_rspec_runner
|
|
123
|
+
debug_child_puts("[henitai-debug-child] build_rspec_runner_return")
|
|
124
|
+
debug_child_puts("[henitai-debug-child] configure_rspec_runner_start")
|
|
125
|
+
configure_rspec_runner(runner)
|
|
126
|
+
debug_child_puts("[henitai-debug-child] configure_rspec_runner_return")
|
|
127
|
+
load_rspec_spec_files(test_files)
|
|
128
|
+
run_rspec_specs(runner)
|
|
129
|
+
rescue SystemExit => e
|
|
130
|
+
debug_child_puts("[henitai-debug-child] runner_run_system_exit status=#{e.status.inspect}")
|
|
131
|
+
raise
|
|
132
|
+
ensure
|
|
133
|
+
debug_child_puts("[henitai-debug-child] runner_run_ensure")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def build_rspec_runner
|
|
137
|
+
# @type var empty_args: Array[String]
|
|
138
|
+
empty_args = []
|
|
139
|
+
configuration_options = ::RSpec::Core.const_get(:ConfigurationOptions).new(empty_args)
|
|
140
|
+
::RSpec::Core::Runner.__send__(:new, configuration_options)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def configure_rspec_runner(runner)
|
|
144
|
+
debug_child_puts("[henitai-debug-child] trap_interrupt_start")
|
|
145
|
+
::RSpec::Core::Runner.__send__(:trap_interrupt)
|
|
146
|
+
debug_child_puts("[henitai-debug-child] trap_interrupt_return")
|
|
147
|
+
debug_child_puts("[henitai-debug-child] runner_configure_start")
|
|
148
|
+
runner.send(:configure, $stderr, $stdout)
|
|
149
|
+
debug_child_puts("[henitai-debug-child] runner_configure_return")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def load_rspec_spec_files(test_files)
|
|
153
|
+
debug_child_puts("[henitai-debug-child] load_spec_files_start")
|
|
154
|
+
::RSpec.__send__(:configuration).files_to_run = test_files.map do |file|
|
|
155
|
+
File.expand_path(file)
|
|
156
|
+
end
|
|
157
|
+
::RSpec.__send__(:configuration).load_spec_files
|
|
158
|
+
debug_child_example_count("after_load")
|
|
159
|
+
debug_child_puts("[henitai-debug-child] load_spec_files_return")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def run_rspec_specs(runner)
|
|
163
|
+
debug_child_puts("[henitai-debug-child] run_specs_start")
|
|
164
|
+
result = runner.send(:run_specs, ::RSpec.__send__(:world).ordered_example_groups)
|
|
165
|
+
debug_child_puts("[henitai-debug-child] run_specs_return result=#{result.inspect}")
|
|
166
|
+
result
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def debug_child? = ENV["HENITAI_DEBUG_CHILD"] == "1"
|
|
170
|
+
|
|
171
|
+
def debug_child_puts(message)
|
|
172
|
+
$stdout.puts(message)
|
|
173
|
+
$stdout.flush
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def debug_child_rspec_trace(test_files:, rspec_options:, rspec_argv:)
|
|
177
|
+
return unless debug_child?
|
|
178
|
+
|
|
179
|
+
files_exist = test_files.map { |f| [f, File.exist?(f)] }.inspect
|
|
180
|
+
loaded_features = loaded_feature_map(test_files).inspect # steep:ignore Ruby::NoMethod
|
|
181
|
+
|
|
182
|
+
debug_child_puts(
|
|
183
|
+
"[henitai-debug-child] cwd=#{Dir.pwd}\n" \
|
|
184
|
+
"[henitai-debug-child] files_exist=#{files_exist}\n" \
|
|
185
|
+
"[henitai-debug-child] loaded_features_check=#{loaded_features}\n" \
|
|
186
|
+
"[henitai-debug-child] test_files=#{test_files.inspect}\n" \
|
|
187
|
+
"[henitai-debug-child] rspec_options=#{rspec_options.inspect}\n" \
|
|
188
|
+
"[henitai-debug-child] rspec_argv=#{rspec_argv.inspect}"
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def debug_child_rspec_exit(status)
|
|
193
|
+
return unless debug_child?
|
|
194
|
+
|
|
195
|
+
debug_child_puts("[henitai-debug-child] RSpec result=#{status.inspect}")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def suppress_simplecov!
|
|
199
|
+
CoverageRuntimeSuppressors.suppress_simplecov!
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def suppress_coverage!
|
|
203
|
+
CoverageRuntimeSuppressors.suppress_coverage!
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def debug_child_example_count(stage) # steep:ignore Ruby::UndeclaredMethodDefinition
|
|
207
|
+
return unless debug_child?
|
|
208
|
+
|
|
209
|
+
count = rspec_world_example_count
|
|
210
|
+
debug_child_puts(
|
|
211
|
+
"[henitai-debug-child] rspec_world_example_count_#{stage}=#{count.inspect}"
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def debug_child_activation_start(mutant_id)
|
|
216
|
+
return unless debug_child?
|
|
217
|
+
|
|
218
|
+
debug_child_puts("[henitai-debug-child] activate_start mutant=#{mutant_id}")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def debug_child_activation_end(activation_result, test_files:)
|
|
222
|
+
return unless debug_child?
|
|
223
|
+
|
|
224
|
+
debug_child_puts(
|
|
225
|
+
"[henitai-debug-child] activate_end result=#{activation_result.inspect}\n" \
|
|
226
|
+
"[henitai-debug-child] run_tests_start test_files=#{test_files.inspect}"
|
|
227
|
+
)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def debug_child_mutant_meta(mutant)
|
|
231
|
+
stable_id = mutant.respond_to?(:stable_id) ? mutant.stable_id : nil
|
|
232
|
+
operator = mutant.respond_to?(:operator) ? mutant.operator : nil
|
|
233
|
+
has_subject_expression =
|
|
234
|
+
mutant.respond_to?(:subject) && mutant.subject.respond_to?(:expression)
|
|
235
|
+
subject_expression = has_subject_expression ? mutant.subject.expression : nil
|
|
236
|
+
location = mutant.respond_to?(:location) ? mutant.location.inspect : nil
|
|
237
|
+
|
|
238
|
+
debug_child_puts(
|
|
239
|
+
"[henitai-debug-child] mutant_meta stableId=#{stable_id}\n" \
|
|
240
|
+
"[henitai-debug-child] mutant_meta operator=#{operator}\n" \
|
|
241
|
+
"[henitai-debug-child] mutant_meta subject=#{subject_expression}\n" \
|
|
242
|
+
"[henitai-debug-child] mutant_meta location=#{location}\n"
|
|
243
|
+
)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def debug_child_activation_check
|
|
247
|
+
location = begin
|
|
248
|
+
Henitai::Runner.instance_method(:resolve_subjects).source_location&.join(":")
|
|
249
|
+
rescue StandardError
|
|
250
|
+
nil
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
debug_child_puts(
|
|
254
|
+
"[henitai-debug-child] activation_check resolve_subjects_location=#{location}\n"
|
|
255
|
+
)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def loaded_feature_map(test_files) = test_files.map { |file| [file, loaded_feature?(file)] } # steep:ignore Ruby::UndeclaredMethodDefinition
|
|
259
|
+
|
|
260
|
+
def loaded_feature?(file) # steep:ignore Ruby::UndeclaredMethodDefinition
|
|
261
|
+
expanded = File.expand_path(file)
|
|
262
|
+
candidates = [expanded, "#{expanded}.rb", file, "#{file}.rb"].uniq
|
|
263
|
+
$LOADED_FEATURES.any? do |feature|
|
|
264
|
+
normalized = begin
|
|
265
|
+
File.expand_path(feature)
|
|
266
|
+
rescue StandardError
|
|
267
|
+
feature
|
|
268
|
+
end
|
|
269
|
+
candidates.include?(feature) || candidates.include?(normalized)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def rspec_world_example_count # steep:ignore Ruby::UndeclaredMethodDefinition
|
|
274
|
+
world = ::RSpec.__send__(:world)
|
|
275
|
+
world.example_count
|
|
276
|
+
rescue StandardError
|
|
277
|
+
nil
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def debug_child_timeout_dump(pid)
|
|
281
|
+
return unless debug_child?
|
|
282
|
+
|
|
283
|
+
debug_child_puts("[henitai-debug-child] timeout_signal_sent pid=#{pid}")
|
|
284
|
+
Process.kill(:USR1, pid)
|
|
285
|
+
pause(0.2)
|
|
286
|
+
rescue Errno::ESRCH
|
|
287
|
+
nil
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def install_debug_timeout_trap
|
|
291
|
+
Signal.trap("USR1") { debug_child_thread_dump("timeout") }
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def debug_child_thread_dump(reason)
|
|
295
|
+
return unless debug_child?
|
|
296
|
+
|
|
297
|
+
debug_child_puts("[henitai-debug-child] thread_dump reason=#{reason}")
|
|
298
|
+
Thread.list.each_with_index do |thread, index|
|
|
299
|
+
debug_child_puts(
|
|
300
|
+
"[henitai-debug-child] thread index=#{index} id=#{thread.object_id} " \
|
|
301
|
+
"status=#{thread.status.inspect}"
|
|
302
|
+
)
|
|
303
|
+
Array(thread.backtrace).each do |line|
|
|
304
|
+
debug_child_puts("[henitai-debug-child] #{line}")
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
# rubocop:enable Metrics/ModuleLength
|
|
310
|
+
|
|
111
311
|
# Integration adapter for RSpec.
|
|
112
312
|
#
|
|
113
313
|
# This class exists as the stable public entry point for the RSpec
|
|
@@ -127,6 +327,8 @@ module Henitai
|
|
|
127
327
|
|
|
128
328
|
# Base class for all integrations.
|
|
129
329
|
class Base
|
|
330
|
+
include ChildDebugSupport
|
|
331
|
+
|
|
130
332
|
# @param subject [Subject]
|
|
131
333
|
# @return [Array<String>] paths to test files that cover this subject
|
|
132
334
|
def select_tests(subject)
|
|
@@ -148,26 +350,33 @@ module Henitai
|
|
|
148
350
|
raise NotImplementedError
|
|
149
351
|
end
|
|
150
352
|
|
|
353
|
+
# Fork a child process for the mutant without waiting for it to finish.
|
|
354
|
+
# Returns a ChildHandle carrying the OS pid and log file paths.
|
|
355
|
+
# The caller is responsible for waiting and cleanup.
|
|
356
|
+
#
|
|
357
|
+
# @param mutant [Mutant]
|
|
358
|
+
# @param test_files [Array<String>]
|
|
359
|
+
# @return [RspecProcessRunner::ChildHandle]
|
|
360
|
+
def spawn_mutant(mutant:, test_files:)
|
|
361
|
+
raise NotImplementedError
|
|
362
|
+
end
|
|
363
|
+
|
|
151
364
|
def per_test_coverage_supported?
|
|
152
365
|
false
|
|
153
366
|
end
|
|
154
367
|
|
|
155
368
|
def wait_with_timeout(pid, timeout)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
loop do
|
|
159
|
-
wait_result = Process.wait(pid, Process::WNOHANG)
|
|
160
|
-
return Process.last_status if wait_result
|
|
369
|
+
wakeup = Henitai.const_get(:ProcessWakeup).new.install
|
|
370
|
+
return Process.last_status if wait_nonblocking(pid)
|
|
161
371
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
372
|
+
wakeup.wait(timeout)
|
|
373
|
+
wakeup.drain
|
|
374
|
+
return Process.last_status if wait_nonblocking(pid)
|
|
375
|
+
return Process.last_status if wait_nonblocking(pid)
|
|
165
376
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
pause(0.01)
|
|
170
|
-
end
|
|
377
|
+
handle_timeout(pid)
|
|
378
|
+
ensure
|
|
379
|
+
wakeup&.close
|
|
171
380
|
end
|
|
172
381
|
|
|
173
382
|
def reap_child(pid)
|
|
@@ -177,13 +386,22 @@ module Henitai
|
|
|
177
386
|
end
|
|
178
387
|
|
|
179
388
|
def cleanup_process_group(pid)
|
|
389
|
+
grace_period = 2.0
|
|
390
|
+
wakeup = Henitai.const_get(:ProcessWakeup).new.install
|
|
180
391
|
Process.kill(:SIGTERM, -pid)
|
|
181
|
-
|
|
392
|
+
return if wait_nonblocking(pid)
|
|
393
|
+
|
|
394
|
+
wakeup.wait(grace_period)
|
|
395
|
+
wakeup.drain
|
|
396
|
+
return if wait_nonblocking(pid)
|
|
397
|
+
|
|
182
398
|
Process.kill(:SIGKILL, -pid)
|
|
183
399
|
rescue Errno::EPERM
|
|
184
400
|
cleanup_child_process(pid)
|
|
185
401
|
rescue Errno::ESRCH
|
|
186
402
|
nil
|
|
403
|
+
ensure
|
|
404
|
+
wakeup&.close
|
|
187
405
|
end
|
|
188
406
|
|
|
189
407
|
private
|
|
@@ -194,6 +412,7 @@ module Henitai
|
|
|
194
412
|
|
|
195
413
|
def handle_timeout(pid)
|
|
196
414
|
begin
|
|
415
|
+
debug_child_timeout_dump(pid)
|
|
197
416
|
cleanup_process_group(pid)
|
|
198
417
|
ensure
|
|
199
418
|
reap_child(pid)
|
|
@@ -202,21 +421,32 @@ module Henitai
|
|
|
202
421
|
end
|
|
203
422
|
|
|
204
423
|
def cleanup_child_process(pid)
|
|
424
|
+
grace_period = 2.0
|
|
425
|
+
wakeup = Henitai.const_get(:ProcessWakeup).new.install
|
|
205
426
|
Process.kill(:SIGTERM, pid)
|
|
206
|
-
|
|
427
|
+
return if wait_nonblocking(pid)
|
|
428
|
+
|
|
429
|
+
wakeup.wait(grace_period)
|
|
430
|
+
wakeup.drain
|
|
431
|
+
return if wait_nonblocking(pid)
|
|
432
|
+
|
|
207
433
|
Process.kill(:SIGKILL, pid)
|
|
208
434
|
rescue Errno::EPERM, Errno::ESRCH
|
|
209
435
|
nil
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
def rspec_options
|
|
213
|
-
[]
|
|
436
|
+
ensure
|
|
437
|
+
wakeup&.close
|
|
214
438
|
end
|
|
215
439
|
|
|
216
440
|
def subprocess_env
|
|
217
441
|
{ "PARALLEL_WORKERS" => "1" }
|
|
218
442
|
end
|
|
219
443
|
|
|
444
|
+
def wait_nonblocking(pid)
|
|
445
|
+
Process.wait(pid, Process::WNOHANG)
|
|
446
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
447
|
+
nil
|
|
448
|
+
end
|
|
449
|
+
|
|
220
450
|
def scenario_log_support
|
|
221
451
|
@scenario_log_support ||= ScenarioLogSupport.new
|
|
222
452
|
end
|
|
@@ -241,6 +471,42 @@ module Henitai
|
|
|
241
471
|
end
|
|
242
472
|
end
|
|
243
473
|
end
|
|
474
|
+
|
|
475
|
+
def with_non_interactive_stdin
|
|
476
|
+
original_stdin = $stdin
|
|
477
|
+
$stdin = StringIO.new
|
|
478
|
+
yield
|
|
479
|
+
ensure
|
|
480
|
+
$stdin = original_stdin
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def run_tests(test_files)
|
|
484
|
+
require "rspec/core"
|
|
485
|
+
::RSpec.__send__(:configuration).fail_if_no_examples = true
|
|
486
|
+
debug_child_rspec_trace(test_files:, rspec_options: [], rspec_argv: test_files)
|
|
487
|
+
debug_child_example_count("before_run") # steep:ignore Ruby::NoMethod
|
|
488
|
+
debug_child_puts("[henitai-debug-child] runner_run_start")
|
|
489
|
+
status = run_rspec_runner(test_files)
|
|
490
|
+
debug_child_puts("[henitai-debug-child] runner_run_return status=#{status.inspect}")
|
|
491
|
+
debug_child_example_count("after_run") # steep:ignore Ruby::NoMethod
|
|
492
|
+
debug_child_rspec_exit(status)
|
|
493
|
+
return status if status.is_a?(Integer)
|
|
494
|
+
|
|
495
|
+
status == true ? 0 : 1
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def run_child_activation_and_tests(mutant:, test_files:, log_paths:)
|
|
499
|
+
scenario_log_support.with_coverage_dir(mutant.id) do
|
|
500
|
+
scenario_log_support.capture_child_output(log_paths) do
|
|
501
|
+
debug_child_mutant_meta(mutant) if debug_child?
|
|
502
|
+
debug_child_activation_start(mutant.id)
|
|
503
|
+
activation_result = Mutant::Activator.activate!(mutant)
|
|
504
|
+
debug_child_activation_check if debug_child?
|
|
505
|
+
debug_child_activation_end(activation_result, test_files:)
|
|
506
|
+
activation_result == :compile_error ? 2 : run_tests(test_files)
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
end
|
|
244
510
|
end
|
|
245
511
|
|
|
246
512
|
# RSpec integration adapter.
|
|
@@ -268,8 +534,11 @@ module Henitai
|
|
|
268
534
|
fallback_spec_files(subject)
|
|
269
535
|
end
|
|
270
536
|
|
|
271
|
-
def test_files
|
|
272
|
-
|
|
537
|
+
def test_files = spec_files
|
|
538
|
+
|
|
539
|
+
def spawn_mutant(mutant:, test_files:)
|
|
540
|
+
log_paths = scenario_log_paths(mutant_log_name(mutant))
|
|
541
|
+
RspecProcessRunner.new.spawn_mutant(self, mutant:, test_files:, log_paths:)
|
|
273
542
|
end
|
|
274
543
|
|
|
275
544
|
def run_mutant(mutant:, test_files:, timeout:)
|
|
@@ -288,18 +557,30 @@ module Henitai
|
|
|
288
557
|
[
|
|
289
558
|
"bundle", "exec", "ruby",
|
|
290
559
|
"-r", "henitai/rspec_coverage_formatter",
|
|
291
|
-
"-
|
|
292
|
-
|
|
293
|
-
"--format", "Henitai::CoverageFormatter"
|
|
560
|
+
"-e", rspec_suite_runner_script,
|
|
561
|
+
*test_files
|
|
294
562
|
]
|
|
295
563
|
end
|
|
296
564
|
|
|
297
|
-
def
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
return status if status.is_a?(Integer)
|
|
565
|
+
def rspec_suite_runner_script
|
|
566
|
+
<<~RUBY
|
|
567
|
+
require "rspec/core"
|
|
301
568
|
|
|
302
|
-
|
|
569
|
+
test_files = ARGV.map { |file| File.expand_path(file) }
|
|
570
|
+
config = RSpec.configuration
|
|
571
|
+
options = RSpec::Core::ConfigurationOptions.new(
|
|
572
|
+
["--format", "progress", "--format", "Henitai::CoverageFormatter"]
|
|
573
|
+
)
|
|
574
|
+
runner = RSpec::Core::Runner.send(:new, options)
|
|
575
|
+
|
|
576
|
+
RSpec::Core::Runner.send(:trap_interrupt)
|
|
577
|
+
runner.send(:configure, $stderr, $stdout)
|
|
578
|
+
config.files_to_run = test_files
|
|
579
|
+
config.load_spec_files
|
|
580
|
+
|
|
581
|
+
status = runner.send(:run_specs, RSpec.world.ordered_example_groups)
|
|
582
|
+
exit(status.is_a?(Integer) ? status : (status == true ? 0 : 1))
|
|
583
|
+
RUBY
|
|
303
584
|
end
|
|
304
585
|
|
|
305
586
|
def scenario_log_paths(name)
|
|
@@ -326,8 +607,10 @@ module Henitai
|
|
|
326
607
|
end
|
|
327
608
|
|
|
328
609
|
def spec_files
|
|
329
|
-
|
|
330
|
-
|
|
610
|
+
@spec_files ||= begin
|
|
611
|
+
paths = Dir.glob("spec/**/*_spec.rb")
|
|
612
|
+
paths - excluded_spec_files
|
|
613
|
+
end
|
|
331
614
|
end
|
|
332
615
|
|
|
333
616
|
def fallback_spec_files(subject)
|
|
@@ -343,7 +626,7 @@ module Henitai
|
|
|
343
626
|
end
|
|
344
627
|
|
|
345
628
|
def excluded_spec_files
|
|
346
|
-
rspec_exclude_patterns.flat_map { |pattern| Dir.glob(pattern) }.uniq
|
|
629
|
+
@excluded_spec_files ||= rspec_exclude_patterns.flat_map { |pattern| Dir.glob(pattern) }.uniq
|
|
347
630
|
end
|
|
348
631
|
|
|
349
632
|
def rspec_exclude_patterns
|
|
@@ -441,16 +724,19 @@ module Henitai
|
|
|
441
724
|
def run_in_child(mutant:, test_files:, log_paths:)
|
|
442
725
|
Thread.report_on_exception = false
|
|
443
726
|
with_subprocess_env do
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
end
|
|
727
|
+
suppress_simplecov!
|
|
728
|
+
suppress_coverage!
|
|
729
|
+
install_debug_timeout_trap if debug_child?
|
|
730
|
+
with_non_interactive_stdin do
|
|
731
|
+
run_child_activation_and_tests(mutant:, test_files:, log_paths:)
|
|
450
732
|
end
|
|
451
733
|
end
|
|
452
734
|
end
|
|
453
735
|
|
|
736
|
+
def mutant_log_name(mutant)
|
|
737
|
+
"mutant-#{mutant.id}"
|
|
738
|
+
end
|
|
739
|
+
|
|
454
740
|
def read_log_file(path)
|
|
455
741
|
return "" unless File.exist?(path)
|
|
456
742
|
|
|
@@ -497,6 +783,38 @@ module Henitai
|
|
|
497
783
|
def start(*_args) = nil
|
|
498
784
|
end
|
|
499
785
|
|
|
786
|
+
# Suppresses expensive and irrelevant coverage startup/teardown during
|
|
787
|
+
# mutant child runs. Coverage artifacts are only required during the
|
|
788
|
+
# dedicated bootstrap phase.
|
|
789
|
+
module CoverageRuntimeSuppressors
|
|
790
|
+
def self.suppress_simplecov!
|
|
791
|
+
require "simplecov"
|
|
792
|
+
sc = Object.const_get(:SimpleCov) # steep:ignore Ruby::UnknownConstant
|
|
793
|
+
sc.external_at_exit = true if sc.respond_to?(:external_at_exit=)
|
|
794
|
+
return if sc.singleton_class.ancestors.include?(SimpleCovStartSuppressor)
|
|
795
|
+
|
|
796
|
+
sc.singleton_class.prepend(SimpleCovStartSuppressor)
|
|
797
|
+
rescue LoadError, NameError
|
|
798
|
+
nil
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
def self.suppress_coverage!
|
|
802
|
+
require "coverage"
|
|
803
|
+
cov = Object.const_get(:Coverage) # steep:ignore Ruby::UnknownConstant
|
|
804
|
+
return if cov.singleton_class.ancestors.include?(CoverageStartSuppressor)
|
|
805
|
+
|
|
806
|
+
cov.singleton_class.prepend(CoverageStartSuppressor)
|
|
807
|
+
rescue LoadError, NameError
|
|
808
|
+
nil
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
# Prepended onto the coverage gem's Coverage singleton to turn start
|
|
813
|
+
# into a no-op during mutant child runs.
|
|
814
|
+
module CoverageStartSuppressor
|
|
815
|
+
def start(*_args) = nil
|
|
816
|
+
end
|
|
817
|
+
|
|
500
818
|
# Minitest integration adapter.
|
|
501
819
|
#
|
|
502
820
|
# Coverage formatter injection remains implemented in the RSpec child
|
|
@@ -512,6 +830,11 @@ module Henitai
|
|
|
512
830
|
super
|
|
513
831
|
end
|
|
514
832
|
|
|
833
|
+
def spawn_mutant(mutant:, test_files:)
|
|
834
|
+
setup_load_path
|
|
835
|
+
super
|
|
836
|
+
end
|
|
837
|
+
|
|
515
838
|
def run_in_child(mutant:, test_files:, log_paths:)
|
|
516
839
|
ENV["RAILS_ENV"] = "test" unless ENV["RAILS_ENV"] == "test"
|
|
517
840
|
preload_environment
|
|
@@ -539,6 +862,7 @@ module Henitai
|
|
|
539
862
|
|
|
540
863
|
def run_tests(test_files)
|
|
541
864
|
suppress_simplecov!
|
|
865
|
+
suppress_minitest_autorun!
|
|
542
866
|
test_files.each { |file| require File.expand_path(file) }
|
|
543
867
|
# @type var empty_args: Array[String]
|
|
544
868
|
empty_args = []
|
|
@@ -558,6 +882,20 @@ module Henitai
|
|
|
558
882
|
$LOAD_PATH.unshift(test_dir) unless $LOAD_PATH.include?(test_dir)
|
|
559
883
|
end
|
|
560
884
|
|
|
885
|
+
def suppress_minitest_autorun!
|
|
886
|
+
require "minitest"
|
|
887
|
+
singleton_class = ::Minitest.singleton_class
|
|
888
|
+
suppressor = @minitest_autorun_suppressor ||= Module.new.tap do |mod|
|
|
889
|
+
mod.define_method(:autorun) { nil }
|
|
890
|
+
end
|
|
891
|
+
return if singleton_class.ancestors.include?(suppressor)
|
|
892
|
+
|
|
893
|
+
singleton_class.prepend(suppressor)
|
|
894
|
+
nil
|
|
895
|
+
rescue LoadError, NameError
|
|
896
|
+
nil
|
|
897
|
+
end
|
|
898
|
+
|
|
561
899
|
def suppress_simplecov!
|
|
562
900
|
require "simplecov"
|
|
563
901
|
sc = Object.const_get(:SimpleCov) # steep:ignore Ruby::UnknownConstant
|
|
@@ -568,6 +906,16 @@ module Henitai
|
|
|
568
906
|
nil
|
|
569
907
|
end
|
|
570
908
|
|
|
909
|
+
def suppress_coverage!
|
|
910
|
+
require "coverage"
|
|
911
|
+
cov = Object.const_get(:Coverage) # steep:ignore Ruby::UnknownConstant
|
|
912
|
+
return if cov.singleton_class.ancestors.include?(CoverageStartSuppressor)
|
|
913
|
+
|
|
914
|
+
cov.singleton_class.prepend(CoverageStartSuppressor)
|
|
915
|
+
rescue LoadError, NameError
|
|
916
|
+
nil
|
|
917
|
+
end
|
|
918
|
+
|
|
571
919
|
def subprocess_env
|
|
572
920
|
env = super
|
|
573
921
|
env["RAILS_ENV"] = "test" unless ENV["RAILS_ENV"] == "test"
|
|
@@ -6,6 +6,7 @@ require "unparser"
|
|
|
6
6
|
module Henitai
|
|
7
7
|
class Mutant
|
|
8
8
|
# Activates a mutant inside the forked child process.
|
|
9
|
+
# rubocop:disable Metrics/ClassLength
|
|
9
10
|
class Activator
|
|
10
11
|
# Filters "already initialized constant" C-level warnings that fire when
|
|
11
12
|
# a source file is loaded into a process that already has the constant
|
|
@@ -38,16 +39,26 @@ module Henitai
|
|
|
38
39
|
new.activate!(mutant)
|
|
39
40
|
end
|
|
40
41
|
|
|
42
|
+
# Returns the +define_method+ source string for +mutant+ without
|
|
43
|
+
# actually evaluating it. Used to pre-compute activation recipes.
|
|
44
|
+
# Returns nil if the source cannot be computed (e.g. unsupported AST node).
|
|
45
|
+
def self.activation_source_for(mutant)
|
|
46
|
+
new.send(:method_source, mutant)
|
|
47
|
+
rescue StandardError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
41
51
|
def activate!(mutant)
|
|
42
52
|
subject = mutant.subject
|
|
43
53
|
raise ArgumentError, "Cannot activate wildcard subjects" if subject.method_name.nil?
|
|
44
54
|
|
|
55
|
+
source = mutant.precomputed_activation_source || method_source(mutant)
|
|
45
56
|
target = target_for(subject)
|
|
46
57
|
Henitai::WarningSilencer.silence do
|
|
47
|
-
target.class_eval(
|
|
58
|
+
target.class_eval(source, __FILE__, __LINE__ + 1)
|
|
48
59
|
nil
|
|
49
60
|
end
|
|
50
|
-
rescue Unparser::UnsupportedNodeError
|
|
61
|
+
rescue Unparser::UnsupportedNodeError, SyntaxError
|
|
51
62
|
:compile_error
|
|
52
63
|
end
|
|
53
64
|
|
|
@@ -266,5 +277,6 @@ module Henitai
|
|
|
266
277
|
raise Unparser::UnsupportedNodeError, e.message
|
|
267
278
|
end
|
|
268
279
|
end
|
|
280
|
+
# rubocop:enable Metrics/ClassLength
|
|
269
281
|
end
|
|
270
282
|
end
|
data/lib/henitai/mutant.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
|
+
require_relative "mutant_identity"
|
|
4
5
|
|
|
5
6
|
module Henitai
|
|
6
7
|
# Represents a single syntactic mutation applied to a Subject.
|
|
@@ -34,7 +35,8 @@ module Henitai
|
|
|
34
35
|
].freeze
|
|
35
36
|
|
|
36
37
|
attr_reader :id, :subject, :operator, :original_node, :mutated_node,
|
|
37
|
-
:mutation_type, :description, :location
|
|
38
|
+
:mutation_type, :description, :location,
|
|
39
|
+
:precomputed_stable_id, :precomputed_activation_source
|
|
38
40
|
attr_accessor :status, :killing_test, :duration, :covered_by, :tests_completed
|
|
39
41
|
|
|
40
42
|
# @param subject [Subject] the subject being mutated
|
|
@@ -42,7 +44,9 @@ module Henitai
|
|
|
42
44
|
# @param nodes [Hash] AST nodes with :original and :mutated entries
|
|
43
45
|
# @param description [String] human-readable description of the mutation
|
|
44
46
|
# @param location [Hash] { file:, start_line:, end_line:, start_col:, end_col: }
|
|
45
|
-
|
|
47
|
+
# rubocop:disable Metrics/ParameterLists
|
|
48
|
+
def initialize(subject:, operator:, nodes:, description:, location:,
|
|
49
|
+
precomputed_stable_id: nil, precomputed_activation_source: nil)
|
|
46
50
|
@id = SecureRandom.uuid
|
|
47
51
|
@subject = subject
|
|
48
52
|
@operator = operator
|
|
@@ -50,12 +54,19 @@ module Henitai
|
|
|
50
54
|
@mutated_node = nodes.fetch(:mutated)
|
|
51
55
|
@description = description
|
|
52
56
|
@location = location
|
|
57
|
+
@precomputed_stable_id = precomputed_stable_id
|
|
58
|
+
@precomputed_activation_source = precomputed_activation_source
|
|
53
59
|
@status = :pending
|
|
54
60
|
@killing_test = nil
|
|
55
61
|
@duration = nil
|
|
56
62
|
@covered_by = nil
|
|
57
63
|
@tests_completed = nil
|
|
58
64
|
end
|
|
65
|
+
# rubocop:enable Metrics/ParameterLists
|
|
66
|
+
|
|
67
|
+
def stable_id
|
|
68
|
+
@stable_id ||= @precomputed_stable_id || MutantIdentity.stable_id(self)
|
|
69
|
+
end
|
|
59
70
|
|
|
60
71
|
def killed? = @status == :killed
|
|
61
72
|
def survived? = @status == :survived
|
|
@@ -68,15 +68,34 @@ module Henitai
|
|
|
68
68
|
|
|
69
69
|
private
|
|
70
70
|
|
|
71
|
-
def walk(node)
|
|
71
|
+
def walk(node, parent: nil)
|
|
72
72
|
return unless node.is_a?(Parser::AST::Node)
|
|
73
73
|
|
|
74
|
+
# Str children of a non-heredoc dstr are raw text segments embedded
|
|
75
|
+
# inside a quoted interpolated string. They have no surrounding quotes
|
|
76
|
+
# in the source, so replacing them via source-fragment substitution
|
|
77
|
+
# would insert a quoted literal into the raw-text position and produce
|
|
78
|
+
# a SyntaxError when the mutant is activated.
|
|
79
|
+
# Heredoc dstr children are exempt: the heredoc body is plain text, so
|
|
80
|
+
# inserting "" there stays valid Ruby.
|
|
81
|
+
return if embedded_non_heredoc_dstr_str?(node, parent)
|
|
82
|
+
|
|
74
83
|
apply_operators(node) if node_within_subject_range?(node)
|
|
75
84
|
node.children.each do |child|
|
|
76
|
-
walk(child)
|
|
85
|
+
walk(child, parent: node)
|
|
77
86
|
end
|
|
78
87
|
end
|
|
79
88
|
|
|
89
|
+
def embedded_non_heredoc_dstr_str?(node, parent)
|
|
90
|
+
node.type == :str &&
|
|
91
|
+
parent&.type == :dstr &&
|
|
92
|
+
!heredoc_node?(parent)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def heredoc_node?(node)
|
|
96
|
+
node.location.respond_to?(:heredoc_body) && node.location.heredoc_body
|
|
97
|
+
end
|
|
98
|
+
|
|
80
99
|
def apply_operators(node)
|
|
81
100
|
return if @arid_node_filter.suppressed?(node, @config)
|
|
82
101
|
|