henitai 0.2.0 → 0.2.1
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 +26 -1
- data/README.md +15 -3
- data/assets/schema/henitai.schema.json +6 -0
- data/lib/henitai/cli/clean_command.rb +48 -0
- data/lib/henitai/cli/command_support.rb +51 -0
- data/lib/henitai/cli/init_command.rb +64 -0
- data/lib/henitai/cli/operator_command.rb +95 -0
- data/lib/henitai/cli/options.rb +120 -0
- data/lib/henitai/cli/run_command.rb +103 -0
- data/lib/henitai/cli.rb +16 -404
- data/lib/henitai/configuration.rb +2 -1
- data/lib/henitai/configuration_validator/rules.rb +143 -0
- data/lib/henitai/configuration_validator/scalars.rb +123 -0
- data/lib/henitai/configuration_validator.rb +12 -239
- data/lib/henitai/eager_load.rb +36 -5
- data/lib/henitai/execution_engine.rb +4 -3
- data/lib/henitai/integration/base.rb +171 -0
- data/lib/henitai/integration/child_debug_support.rb +115 -0
- data/lib/henitai/integration/child_runtime_control.rb +50 -0
- data/lib/henitai/integration/coverage_suppression.rb +43 -0
- data/lib/henitai/integration/minitest.rb +133 -0
- data/lib/henitai/integration/mutant_run_support.rb +77 -0
- data/lib/henitai/integration/rspec_child_runner.rb +61 -0
- data/lib/henitai/integration/rspec_test_selection.rb +135 -0
- data/lib/henitai/integration/scenario_log_support.rb +116 -0
- data/lib/henitai/integration.rb +22 -846
- data/lib/henitai/mutant/activator.rb +1 -79
- data/lib/henitai/mutant/parameter_source.rb +98 -0
- data/lib/henitai/mutant.rb +1 -0
- data/lib/henitai/mutant_history_store/sql.rb +72 -0
- data/lib/henitai/mutant_history_store.rb +5 -69
- data/lib/henitai/per_test_coverage_collector.rb +3 -1
- data/lib/henitai/process_worker_runner.rb +48 -334
- data/lib/henitai/reporter.rb +20 -8
- data/lib/henitai/result.rb +17 -15
- data/lib/henitai/runner.rb +59 -182
- data/lib/henitai/slot_scheduler/draining.rb +140 -0
- data/lib/henitai/slot_scheduler/process_control.rb +43 -0
- data/lib/henitai/slot_scheduler.rb +214 -0
- data/lib/henitai/survivor_rerun_strategy.rb +195 -0
- data/lib/henitai/unparse_helper.rb +5 -2
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +2 -0
- data/sig/configuration_validator.rbs +46 -22
- data/sig/henitai.rbs +158 -73
- metadata +25 -2
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Henitai
|
|
4
|
-
# Flat, single-threaded
|
|
4
|
+
# Flat, single-threaded driver for parallel mutation runs.
|
|
5
5
|
#
|
|
6
|
-
# Owns the
|
|
7
|
-
#
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
# Owns the event loop and OS signal handling, and delegates the slot
|
|
7
|
+
# lifecycle (spawning, reaping, timeout detection and the drain/broadcast
|
|
8
|
+
# state machine) to a {SlotScheduler}. Because the scheduler is the sole
|
|
9
|
+
# caller of Process.wait*, there are no races between threads reaping the
|
|
10
|
+
# same child.
|
|
11
|
+
class ProcessWorkerRunner
|
|
11
12
|
# Default bridge to process and signal primitives used by the scheduler.
|
|
12
13
|
class Runtime
|
|
13
14
|
def clock_gettime(clock_id)
|
|
@@ -31,12 +32,8 @@ module Henitai
|
|
|
31
32
|
end
|
|
32
33
|
end
|
|
33
34
|
|
|
34
|
-
#
|
|
35
|
-
|
|
36
|
-
:slot_id, :mutant, :pid, :started_at_monotonic, :timeout,
|
|
37
|
-
:log_paths, :retry_count, :draining, :term_sent_at_monotonic,
|
|
38
|
-
:forced_outcome
|
|
39
|
-
)
|
|
35
|
+
# Loop primitives the {SlotScheduler} reads back from its host.
|
|
36
|
+
attr_reader :worker_count, :runtime, :wakeup
|
|
40
37
|
|
|
41
38
|
def initialize(worker_count:, runtime: Runtime.new, wakeup: nil)
|
|
42
39
|
@worker_count = worker_count
|
|
@@ -45,6 +42,18 @@ module Henitai
|
|
|
45
42
|
@shutdown_requested = false
|
|
46
43
|
end
|
|
47
44
|
|
|
45
|
+
# Number of mutants that required at least one retry during the run.
|
|
46
|
+
# Mirrors the linear path's per-mutant flaky semantics so the engine can
|
|
47
|
+
# report a single, mode-agnostic flaky statistic.
|
|
48
|
+
def flaky_retry_count
|
|
49
|
+
@scheduler ? @scheduler.flaky_retry_count : 0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# True once a graceful shutdown has been requested; read by the scheduler.
|
|
53
|
+
def shutdown_requested?
|
|
54
|
+
@shutdown_requested
|
|
55
|
+
end
|
|
56
|
+
|
|
48
57
|
# Trigger a graceful shutdown from outside the event loop.
|
|
49
58
|
# Safe to call from any thread. The loop observes the flag on its next tick.
|
|
50
59
|
def request_shutdown
|
|
@@ -65,7 +74,7 @@ module Henitai
|
|
|
65
74
|
prepare_run(mutants, integration, config, progress_reporter, options)
|
|
66
75
|
|
|
67
76
|
event_loop
|
|
68
|
-
@results
|
|
77
|
+
@scheduler.results
|
|
69
78
|
ensure
|
|
70
79
|
@wakeup&.close
|
|
71
80
|
@wakeup = nil
|
|
@@ -73,13 +82,23 @@ module Henitai
|
|
|
73
82
|
|
|
74
83
|
private
|
|
75
84
|
|
|
76
|
-
|
|
77
|
-
|
|
85
|
+
def prepare_run(mutants, integration, config, progress_reporter, options)
|
|
86
|
+
@shutdown_requested = false
|
|
87
|
+
@wakeup = Henitai::ProcessWakeup.new.install if @wakeup.nil?
|
|
88
|
+
@scheduler = SlotScheduler.new(
|
|
89
|
+
integration: integration,
|
|
90
|
+
config: config,
|
|
91
|
+
progress_reporter: progress_reporter,
|
|
92
|
+
options: options,
|
|
93
|
+
host: self
|
|
94
|
+
)
|
|
95
|
+
@scheduler.enqueue(mutants)
|
|
96
|
+
end
|
|
78
97
|
|
|
79
98
|
def event_loop
|
|
80
99
|
saved_traps = install_signal_traps
|
|
81
100
|
loop do
|
|
82
|
-
break if done?
|
|
101
|
+
break if @scheduler.done?
|
|
83
102
|
|
|
84
103
|
break if process_cycle == :shutdown
|
|
85
104
|
end
|
|
@@ -89,346 +108,41 @@ module Henitai
|
|
|
89
108
|
end
|
|
90
109
|
|
|
91
110
|
def process_cycle
|
|
92
|
-
fill_idle_slots unless @shutdown_requested
|
|
93
|
-
reap_all_completed_children
|
|
94
|
-
check_timeouts
|
|
95
|
-
fill_idle_slots unless @shutdown_requested
|
|
111
|
+
@scheduler.fill_idle_slots unless @shutdown_requested
|
|
112
|
+
@scheduler.reap_all_completed_children
|
|
113
|
+
@scheduler.check_timeouts
|
|
114
|
+
@scheduler.fill_idle_slots unless @shutdown_requested
|
|
96
115
|
return handle_shutdown if @shutdown_requested
|
|
97
116
|
|
|
98
|
-
drain_draining_slots if draining_slots?
|
|
99
|
-
fill_idle_slots unless @shutdown_requested
|
|
100
|
-
return :done if done?
|
|
117
|
+
@scheduler.drain_draining_slots if @scheduler.draining_slots?
|
|
118
|
+
@scheduler.fill_idle_slots unless @shutdown_requested
|
|
119
|
+
return :done if @scheduler.done?
|
|
101
120
|
|
|
102
121
|
wait_for_next_event
|
|
103
122
|
nil
|
|
104
123
|
end
|
|
105
124
|
|
|
106
125
|
def handle_shutdown
|
|
107
|
-
interrupt_active_slots
|
|
108
|
-
drain_draining_slots
|
|
126
|
+
@scheduler.interrupt_active_slots
|
|
127
|
+
@scheduler.drain_draining_slots
|
|
109
128
|
:shutdown
|
|
110
129
|
end
|
|
111
130
|
|
|
112
|
-
def
|
|
113
|
-
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def fill_idle_slots
|
|
117
|
-
while slots.size < worker_count && !pending.empty?
|
|
118
|
-
mutant = pending.shift
|
|
119
|
-
spawn_into_slot(mutant)
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
def spawn_into_slot(mutant)
|
|
124
|
-
test_files = resolve_test_files(mutant)
|
|
125
|
-
mutant.covered_by = test_files if mutant.respond_to?(:covered_by=)
|
|
126
|
-
mutant.tests_completed = test_files.size if mutant.respond_to?(:tests_completed=)
|
|
127
|
-
handle = integration.spawn_mutant(mutant: mutant, test_files: test_files)
|
|
128
|
-
register_slot(handle, mutant)
|
|
129
|
-
rescue StandardError => e
|
|
130
|
-
record_spawn_failure(mutant, e)
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
def register_slot(handle, mutant)
|
|
134
|
-
slot_id = next_slot_id!
|
|
135
|
-
slot = build_slot(slot_id, mutant, handle)
|
|
136
|
-
slots[slot_id] = slot
|
|
137
|
-
pid_to_slot[handle.pid] = slot_id
|
|
138
|
-
Integration::SchedulerDiagnostics.child_started(handle.pid)
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def build_slot(slot_id, mutant, handle)
|
|
142
|
-
Slot.new(
|
|
143
|
-
slot_id, mutant, handle.pid,
|
|
144
|
-
monotonic_time,
|
|
145
|
-
config.timeout, handle.log_paths, 0, false, nil, nil
|
|
146
|
-
)
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
def reap_all_completed_children
|
|
150
|
-
loop do
|
|
151
|
-
pid, status = runtime.wait2(-1, Process::WNOHANG)
|
|
152
|
-
break unless pid
|
|
153
|
-
|
|
154
|
-
complete_slot(pid, status)
|
|
155
|
-
end
|
|
156
|
-
rescue Errno::ECHILD
|
|
157
|
-
nil
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
def complete_slot(pid, wait_result)
|
|
161
|
-
slot_id = pid_to_slot.delete(pid)
|
|
162
|
-
return unless slot_id
|
|
163
|
-
|
|
164
|
-
slot = slots[slot_id]
|
|
165
|
-
return unless slot
|
|
166
|
-
|
|
167
|
-
Integration::SchedulerDiagnostics.child_ended(pid)
|
|
168
|
-
result = integration.build_result(wait_result, slot.log_paths)
|
|
169
|
-
dispatch_slot_result(slot, result)
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def dispatch_slot_result(slot, result)
|
|
173
|
-
if should_retry?(slot, result)
|
|
174
|
-
retry_slot(slot)
|
|
175
|
-
else
|
|
176
|
-
slots.delete(slot.slot_id)
|
|
177
|
-
slot.mutant.status = result.status
|
|
178
|
-
results << result
|
|
179
|
-
progress_reporter&.progress(slot.mutant, scenario_result: result)
|
|
180
|
-
end
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
# Per-slot timeout check. Must be called after reap_all_completed_children
|
|
184
|
-
# so that naturally-exited processes are already removed from slots.
|
|
185
|
-
def check_timeouts
|
|
186
|
-
now = monotonic_time
|
|
187
|
-
slots.each_value do |slot|
|
|
188
|
-
next if slot.draining
|
|
189
|
-
next unless now >= slot.started_at_monotonic + slot.timeout
|
|
190
|
-
|
|
191
|
-
# Final targeted reap: if the child already exited, classify it normally.
|
|
192
|
-
pid, status = runtime.wait2(slot.pid, Process::WNOHANG)
|
|
193
|
-
if pid
|
|
194
|
-
complete_slot(pid, status)
|
|
195
|
-
else
|
|
196
|
-
slot.forced_outcome = :timeout
|
|
197
|
-
slot.draining = true
|
|
198
|
-
end
|
|
199
|
-
end
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
def draining_slots?
|
|
203
|
-
slots.any? { |_, slot| slot.draining }
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
# Two-phase broadcast cleanup for all slots that are in draining state.
|
|
207
|
-
#
|
|
208
|
-
# Precision rule: before signalling, do one final WNOHANG pass to catch
|
|
209
|
-
# processes that exited naturally in the window between check_timeouts and
|
|
210
|
-
# now. If SIGTERM gets ESRCH, the process is already gone — we must not
|
|
211
|
-
# force-label those as :timeout.
|
|
212
|
-
def drain_draining_slots
|
|
213
|
-
draining = draining_slots
|
|
214
|
-
return if draining.empty?
|
|
215
|
-
|
|
216
|
-
prune_raced_draining_slots(draining)
|
|
217
|
-
|
|
218
|
-
return if draining.empty?
|
|
219
|
-
|
|
220
|
-
broadcast_term(draining)
|
|
221
|
-
wait_for_drain_window
|
|
222
|
-
signal_draining_slots(draining)
|
|
223
|
-
reap_and_remove_draining(draining)
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
def draining_slots
|
|
227
|
-
slots.select { |_, slot| slot.draining }
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
def prune_raced_draining_slots(draining)
|
|
231
|
-
draining.reject! do |_, slot|
|
|
232
|
-
pid, status = wnohang_reap(slot.pid)
|
|
233
|
-
next false unless pid
|
|
234
|
-
|
|
235
|
-
complete_slot(pid, status)
|
|
236
|
-
true
|
|
237
|
-
end
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
def wait_for_drain_window
|
|
241
|
-
@wakeup&.wait(PROCESS_DRAIN_WINDOW)
|
|
131
|
+
def wait_for_next_event
|
|
132
|
+
@wakeup&.wait(@scheduler.next_event_timeout)
|
|
242
133
|
@wakeup&.drain
|
|
243
134
|
end
|
|
244
135
|
|
|
245
|
-
def signal_draining_slots(draining)
|
|
246
|
-
draining.each_value { |slot| signal_process_group(slot.pid, :SIGKILL) }
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
def broadcast_term(draining)
|
|
250
|
-
now = monotonic_time
|
|
251
|
-
draining.each_value do |slot|
|
|
252
|
-
slot.term_sent_at_monotonic = now
|
|
253
|
-
signal_process_group(slot.pid, :SIGTERM)
|
|
254
|
-
end
|
|
255
|
-
end
|
|
256
|
-
|
|
257
|
-
# After SIGKILL window: blocking reap each slot, then build its result.
|
|
258
|
-
#
|
|
259
|
-
# Interrupted slots are cleaned up but produce no result — the scheduler
|
|
260
|
-
# is shutting down and does not emit verdicts for in-flight mutants.
|
|
261
|
-
#
|
|
262
|
-
# For timeout slots: a real exit status only wins if observed before any
|
|
263
|
-
# parent signal was sent. Once SIGTERM has been dispatched, the forced
|
|
264
|
-
# outcome is authoritative — a child handling SIGTERM and exiting 0 must
|
|
265
|
-
# not be misclassified as :survived.
|
|
266
|
-
def reap_and_remove_draining(draining) # rubocop:disable Metrics/AbcSize
|
|
267
|
-
draining.each_value do |slot|
|
|
268
|
-
# One last WNOHANG before blocking: catches processes that exited
|
|
269
|
-
# between SIGKILL and here.
|
|
270
|
-
_, final_status = wnohang_reap(slot.pid)
|
|
271
|
-
reap_pid(slot.pid) unless final_status
|
|
272
|
-
|
|
273
|
-
pid_to_slot.delete(slot.pid)
|
|
274
|
-
slots.delete(slot.slot_id)
|
|
275
|
-
Integration::SchedulerDiagnostics.child_ended(slot.pid)
|
|
276
|
-
|
|
277
|
-
next if slot.forced_outcome == :interrupted
|
|
278
|
-
|
|
279
|
-
result = build_drain_result(slot, final_status)
|
|
280
|
-
slot.mutant.status = result.status
|
|
281
|
-
results << result
|
|
282
|
-
progress_reporter&.progress(slot.mutant, scenario_result: result)
|
|
283
|
-
end
|
|
284
|
-
end
|
|
285
|
-
|
|
286
|
-
# Choose result: use real exit status only if observed before any parent
|
|
287
|
-
# signal was sent. After SIGTERM, the forced outcome is authoritative.
|
|
288
|
-
def build_drain_result(slot, final_status)
|
|
289
|
-
if final_status&.exited? && slot.term_sent_at_monotonic.nil?
|
|
290
|
-
integration.build_result(final_status, slot.log_paths)
|
|
291
|
-
else
|
|
292
|
-
integration.build_result(slot.forced_outcome || :timeout, slot.log_paths)
|
|
293
|
-
end
|
|
294
|
-
end
|
|
295
|
-
|
|
296
136
|
def install_signal_traps
|
|
297
137
|
saved = {}
|
|
298
138
|
%w[INT TERM HUP].each do |sig|
|
|
299
|
-
saved[sig] = runtime.trap(sig) { @shutdown_requested = true }
|
|
139
|
+
saved[sig] = @runtime.trap(sig) { @shutdown_requested = true }
|
|
300
140
|
end
|
|
301
141
|
saved
|
|
302
142
|
end
|
|
303
143
|
|
|
304
144
|
def restore_signal_traps(saved)
|
|
305
|
-
saved&.each { |sig, handler| runtime.trap(sig, handler) }
|
|
306
|
-
end
|
|
307
|
-
|
|
308
|
-
def interrupt_active_slots
|
|
309
|
-
slots.each_value do |slot|
|
|
310
|
-
next if slot.draining
|
|
311
|
-
|
|
312
|
-
slot.forced_outcome = :interrupted
|
|
313
|
-
slot.draining = true
|
|
314
|
-
end
|
|
315
|
-
end
|
|
316
|
-
|
|
317
|
-
def should_retry?(slot, result)
|
|
318
|
-
!@shutdown_requested && result.survived? && slot.retry_count < config.max_flaky_retries.to_i
|
|
319
|
-
end
|
|
320
|
-
|
|
321
|
-
def prepare_run(mutants, integration, config, progress_reporter, options)
|
|
322
|
-
@pending = mutants.dup
|
|
323
|
-
@slots = {}
|
|
324
|
-
@pid_to_slot = {}
|
|
325
|
-
@results = []
|
|
326
|
-
@next_slot_id = 0
|
|
327
|
-
@integration = integration
|
|
328
|
-
@config = config
|
|
329
|
-
@progress_reporter = progress_reporter
|
|
330
|
-
@options = options
|
|
331
|
-
@wakeup = Henitai::ProcessWakeup.new.install if @wakeup.nil?
|
|
332
|
-
end
|
|
333
|
-
|
|
334
|
-
def next_event_timeout
|
|
335
|
-
now = monotonic_time
|
|
336
|
-
slot_timeouts = slots.each_value.filter_map do |slot|
|
|
337
|
-
remaining_slot_timeout(slot, now)
|
|
338
|
-
end
|
|
339
|
-
|
|
340
|
-
slot_timeouts.min
|
|
341
|
-
end
|
|
342
|
-
|
|
343
|
-
def remaining_slot_timeout(slot, now)
|
|
344
|
-
deadline =
|
|
345
|
-
if slot.draining
|
|
346
|
-
slot.term_sent_at_monotonic + PROCESS_DRAIN_WINDOW
|
|
347
|
-
else
|
|
348
|
-
slot.started_at_monotonic + slot.timeout
|
|
349
|
-
end
|
|
350
|
-
remaining = deadline - now
|
|
351
|
-
remaining.positive? ? remaining : 0.0
|
|
352
|
-
end
|
|
353
|
-
|
|
354
|
-
def wait_for_next_event
|
|
355
|
-
@wakeup&.wait(next_event_timeout)
|
|
356
|
-
@wakeup&.drain
|
|
357
|
-
end
|
|
358
|
-
|
|
359
|
-
def retry_slot(slot) # rubocop:disable Metrics/AbcSize
|
|
360
|
-
slot.retry_count += 1
|
|
361
|
-
test_files = resolve_test_files(slot.mutant)
|
|
362
|
-
handle = integration.spawn_mutant(mutant: slot.mutant, test_files: test_files)
|
|
363
|
-
slot.pid = handle.pid
|
|
364
|
-
slot.log_paths = handle.log_paths
|
|
365
|
-
slot.started_at_monotonic = monotonic_time
|
|
366
|
-
slot.draining = false
|
|
367
|
-
slot.term_sent_at_monotonic = nil
|
|
368
|
-
slot.forced_outcome = nil
|
|
369
|
-
pid_to_slot[handle.pid] = slot.slot_id
|
|
370
|
-
Integration::SchedulerDiagnostics.child_started(handle.pid)
|
|
371
|
-
rescue StandardError => e
|
|
372
|
-
slots.delete(slot.slot_id)
|
|
373
|
-
record_spawn_failure(slot.mutant, e)
|
|
374
|
-
end
|
|
375
|
-
|
|
376
|
-
def record_spawn_failure(mutant, error)
|
|
377
|
-
result = ScenarioExecutionResult.new(
|
|
378
|
-
status: :compile_error,
|
|
379
|
-
stdout: "",
|
|
380
|
-
stderr: "spawn failed: #{error.message}",
|
|
381
|
-
log_path: "/dev/null",
|
|
382
|
-
exit_status: nil
|
|
383
|
-
)
|
|
384
|
-
mutant.status = result.status
|
|
385
|
-
results << result
|
|
386
|
-
progress_reporter&.progress(mutant, scenario_result: result)
|
|
387
|
-
end
|
|
388
|
-
|
|
389
|
-
def wnohang_reap(pid)
|
|
390
|
-
runtime.wait2(pid, Process::WNOHANG)
|
|
391
|
-
rescue Errno::ECHILD, Errno::ESRCH
|
|
392
|
-
nil
|
|
393
|
-
end
|
|
394
|
-
|
|
395
|
-
def signal_process_group(pid, signal)
|
|
396
|
-
runtime.kill(signal, -pid)
|
|
397
|
-
rescue Errno::ESRCH
|
|
398
|
-
nil
|
|
399
|
-
rescue Errno::EPERM
|
|
400
|
-
# Process group not yet established; fall back to signalling the pid.
|
|
401
|
-
begin
|
|
402
|
-
runtime.kill(signal, pid)
|
|
403
|
-
rescue Errno::ESRCH
|
|
404
|
-
nil
|
|
405
|
-
end
|
|
406
|
-
end
|
|
407
|
-
|
|
408
|
-
def reap_pid(pid)
|
|
409
|
-
runtime.wait(pid)
|
|
410
|
-
rescue Errno::ECHILD, Errno::ESRCH
|
|
411
|
-
nil
|
|
412
|
-
end
|
|
413
|
-
|
|
414
|
-
def monotonic_time
|
|
415
|
-
runtime.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
416
|
-
end
|
|
417
|
-
|
|
418
|
-
def resolve_test_files(mutant)
|
|
419
|
-
if @options.key?(:test_file_resolver)
|
|
420
|
-
@options[:test_file_resolver].call(mutant)
|
|
421
|
-
elsif @options.key?(:test_files)
|
|
422
|
-
@options[:test_files]
|
|
423
|
-
else
|
|
424
|
-
integration.select_tests(mutant.subject)
|
|
425
|
-
end
|
|
426
|
-
end
|
|
427
|
-
|
|
428
|
-
def next_slot_id!
|
|
429
|
-
id = @next_slot_id
|
|
430
|
-
@next_slot_id += 1
|
|
431
|
-
id
|
|
145
|
+
saved&.each { |sig, handler| @runtime.trap(sig, handler) }
|
|
432
146
|
end
|
|
433
147
|
end
|
|
434
148
|
end
|
data/lib/henitai/reporter.rb
CHANGED
|
@@ -22,9 +22,12 @@ module Henitai
|
|
|
22
22
|
# @param names [Array<String>] reporter names from configuration
|
|
23
23
|
# @param result [Result]
|
|
24
24
|
# @param config [Configuration]
|
|
25
|
-
|
|
25
|
+
# @param history_store [MutantHistoryStore, nil] persistence store the JSON
|
|
26
|
+
# reporter reads trend data from; supplied by the composition root so the
|
|
27
|
+
# reporter does not build infrastructure itself.
|
|
28
|
+
def self.run_all(names:, result:, config:, history_store: nil)
|
|
26
29
|
names.each do |name|
|
|
27
|
-
reporter_class(name).new(config:).report(result)
|
|
30
|
+
reporter_class(name).new(config:, history_store:).report(result)
|
|
28
31
|
end
|
|
29
32
|
end
|
|
30
33
|
|
|
@@ -36,8 +39,12 @@ module Henitai
|
|
|
36
39
|
|
|
37
40
|
# Base class for all reporters.
|
|
38
41
|
class Base
|
|
39
|
-
|
|
42
|
+
# @param history_store [MutantHistoryStore, nil] accepted by every
|
|
43
|
+
# reporter for a uniform factory signature; only the JSON reporter uses
|
|
44
|
+
# it. Ignored elsewhere.
|
|
45
|
+
def initialize(config:, history_store: nil)
|
|
40
46
|
@config = config
|
|
47
|
+
@history_store = history_store
|
|
41
48
|
end
|
|
42
49
|
|
|
43
50
|
# @param result [Result]
|
|
@@ -47,7 +54,7 @@ module Henitai
|
|
|
47
54
|
|
|
48
55
|
private
|
|
49
56
|
|
|
50
|
-
attr_reader :config
|
|
57
|
+
attr_reader :config, :history_store
|
|
51
58
|
end
|
|
52
59
|
|
|
53
60
|
# Terminal reporter.
|
|
@@ -297,12 +304,17 @@ module Henitai
|
|
|
297
304
|
end
|
|
298
305
|
|
|
299
306
|
def write_history_report
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
return unless File.exist?(path)
|
|
307
|
+
store = history_store || default_history_store
|
|
308
|
+
return unless File.exist?(store.path)
|
|
303
309
|
|
|
304
310
|
FileUtils.mkdir_p(File.dirname(history_report_path))
|
|
305
|
-
File.write(history_report_path, JSON.pretty_generate(
|
|
311
|
+
File.write(history_report_path, JSON.pretty_generate(store.trend_report))
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def default_history_store
|
|
315
|
+
MutantHistoryStore.new(
|
|
316
|
+
path: File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME)
|
|
317
|
+
)
|
|
306
318
|
end
|
|
307
319
|
|
|
308
320
|
def history_report_path
|
data/lib/henitai/result.rb
CHANGED
|
@@ -17,18 +17,25 @@ module Henitai
|
|
|
17
17
|
attr_reader :mutants, :started_at, :finished_at, :thresholds, :survivor_stats,
|
|
18
18
|
:session_id, :git_sha
|
|
19
19
|
|
|
20
|
+
# @param source_provider [#call] maps a file path to its source string.
|
|
21
|
+
# Injected so the domain object performs no disk IO; the caller (which
|
|
22
|
+
# already knows the file paths) supplies the contents. The default
|
|
23
|
+
# provider returns "" for every file — callers that need real source in
|
|
24
|
+
# the schema (e.g. the runner) inject a provider primed with file content.
|
|
20
25
|
# rubocop:disable Metrics/ParameterLists
|
|
21
26
|
def initialize(mutants:, started_at:, finished_at:, thresholds: nil,
|
|
22
27
|
partial_rerun: false, survivor_stats: nil,
|
|
23
|
-
session_id: SecureRandom.uuid, git_sha: nil
|
|
24
|
-
|
|
25
|
-
@
|
|
26
|
-
@
|
|
27
|
-
@
|
|
28
|
-
@
|
|
29
|
-
@
|
|
30
|
-
@
|
|
31
|
-
@
|
|
28
|
+
session_id: SecureRandom.uuid, git_sha: nil,
|
|
29
|
+
source_provider: ->(_file) { "" })
|
|
30
|
+
@mutants = mutants
|
|
31
|
+
@started_at = started_at
|
|
32
|
+
@finished_at = finished_at
|
|
33
|
+
@thresholds = DEFAULT_THRESHOLDS.merge(thresholds || {})
|
|
34
|
+
@partial_rerun = partial_rerun
|
|
35
|
+
@survivor_stats = survivor_stats
|
|
36
|
+
@session_id = session_id
|
|
37
|
+
@git_sha = git_sha
|
|
38
|
+
@source_provider = source_provider
|
|
32
39
|
end
|
|
33
40
|
# rubocop:enable Metrics/ParameterLists
|
|
34
41
|
|
|
@@ -129,14 +136,9 @@ module Henitai
|
|
|
129
136
|
|
|
130
137
|
def build_files_section
|
|
131
138
|
mutants.group_by { |m| m.location[:file] }.transform_values do |file_mutants|
|
|
132
|
-
source = begin
|
|
133
|
-
File.read(file_mutants.first.location[:file])
|
|
134
|
-
rescue StandardError
|
|
135
|
-
""
|
|
136
|
-
end
|
|
137
139
|
{
|
|
138
140
|
language: "ruby",
|
|
139
|
-
source
|
|
141
|
+
source: @source_provider.call(file_mutants.first.location[:file]),
|
|
140
142
|
mutants: file_mutants.map { |m| mutant_to_schema(m) }
|
|
141
143
|
}
|
|
142
144
|
end
|