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,105 @@
|
|
|
1
|
+
require 'etc'
|
|
2
|
+
|
|
3
|
+
module MutationTester
|
|
4
|
+
class Configuration
|
|
5
|
+
RUNNER_MODES = %i[auto fork spawn in_memory].freeze
|
|
6
|
+
AUTO_PARALLEL_CAP = 8
|
|
7
|
+
|
|
8
|
+
def self.auto_parallel_processes
|
|
9
|
+
[[Etc.nprocessors, AUTO_PARALLEL_CAP].min, 1].max
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.worker_env_value(index)
|
|
13
|
+
number = index.to_i
|
|
14
|
+
number <= 0 ? '' : (number + 1).to_s
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attr_reader :parallel_processes, :runner, :worker_env_var
|
|
18
|
+
|
|
19
|
+
attr_accessor :timeout,
|
|
20
|
+
:baseline_timeout,
|
|
21
|
+
:mutation_types,
|
|
22
|
+
:reporters,
|
|
23
|
+
:output_dir,
|
|
24
|
+
:minimum_score,
|
|
25
|
+
:fail_on_threshold,
|
|
26
|
+
:verbose,
|
|
27
|
+
:show_file_path,
|
|
28
|
+
:show_progress,
|
|
29
|
+
:test_selection,
|
|
30
|
+
:fail_fast
|
|
31
|
+
|
|
32
|
+
def initialize
|
|
33
|
+
self.parallel_processes = ENV['MUTATION_TESTER_PARALLEL_PROCESSES'] || self.class.auto_parallel_processes
|
|
34
|
+
self.runner = ENV['MUTATION_TESTER_RUNNER'] || :auto
|
|
35
|
+
self.worker_env_var = ENV['MUTATION_TESTER_WORKER_ENV']
|
|
36
|
+
@timeout = 30
|
|
37
|
+
@baseline_timeout = 300
|
|
38
|
+
@mutation_types = {
|
|
39
|
+
arithmetic: true,
|
|
40
|
+
comparison: true,
|
|
41
|
+
logical: true,
|
|
42
|
+
boolean: true,
|
|
43
|
+
number: true,
|
|
44
|
+
string: true,
|
|
45
|
+
conditional: true,
|
|
46
|
+
call_removal: true,
|
|
47
|
+
nil_injection: true,
|
|
48
|
+
argument: true,
|
|
49
|
+
strict_equality: false
|
|
50
|
+
}
|
|
51
|
+
@reporters = %i[console html json]
|
|
52
|
+
@output_dir = 'tmp/mutation_reports'
|
|
53
|
+
@minimum_score = 80.0
|
|
54
|
+
@fail_on_threshold = true
|
|
55
|
+
@verbose = false
|
|
56
|
+
@show_file_path = true
|
|
57
|
+
@show_progress = true
|
|
58
|
+
@test_selection = true
|
|
59
|
+
@fail_fast = false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def merge(options)
|
|
63
|
+
config = dup
|
|
64
|
+
options.each do |key, value|
|
|
65
|
+
config.public_send("#{key}=", value) if config.respond_to?("#{key}=")
|
|
66
|
+
end
|
|
67
|
+
config
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def initialize_copy(source)
|
|
71
|
+
super
|
|
72
|
+
@mutation_types = source.mutation_types.dup
|
|
73
|
+
@reporters = source.reporters.dup
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def parallel_processes=(value)
|
|
77
|
+
count = value.to_i
|
|
78
|
+
if count < 1
|
|
79
|
+
warn "[MutationTester] parallel_processes must be >= 1; got #{value.inspect}, falling back to 1 (serial execution)."
|
|
80
|
+
count = 1
|
|
81
|
+
end
|
|
82
|
+
@parallel_processes = count
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def runner=(value)
|
|
86
|
+
mode = value.to_s.strip.downcase.to_sym
|
|
87
|
+
unless RUNNER_MODES.include?(mode)
|
|
88
|
+
warn "[MutationTester] runner must be one of #{RUNNER_MODES.join(", ")}; got #{value.inspect}, falling back to auto."
|
|
89
|
+
mode = :auto
|
|
90
|
+
end
|
|
91
|
+
@runner = mode
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def worker_env_var=(value)
|
|
95
|
+
normalized = value.to_s.strip
|
|
96
|
+
@worker_env_var = normalized.empty? ? nil : normalized
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def worker_env_assignment(index)
|
|
100
|
+
return nil unless @worker_env_var
|
|
101
|
+
|
|
102
|
+
{ @worker_env_var => self.class.worker_env_value(index) }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
require_relative 'progress_display'
|
|
2
|
+
|
|
3
|
+
module MutationTester
|
|
4
|
+
class Core
|
|
5
|
+
attr_reader :source_file, :spec_file, :mutations, :results, :config
|
|
6
|
+
|
|
7
|
+
def initialize(source_file, spec_file, config = MutationTester.configuration)
|
|
8
|
+
@source_file = File.expand_path(source_file)
|
|
9
|
+
@spec_file = File.expand_path(spec_file)
|
|
10
|
+
@config = config
|
|
11
|
+
@mutations = []
|
|
12
|
+
@results = []
|
|
13
|
+
@parse_failed = false
|
|
14
|
+
MutationRunner.recover_in_place_backup(@source_file)
|
|
15
|
+
@original_content = File.read(@source_file)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run
|
|
19
|
+
print_header
|
|
20
|
+
return false unless run_original_tests
|
|
21
|
+
|
|
22
|
+
generate_mutations
|
|
23
|
+
return false if @parse_failed
|
|
24
|
+
|
|
25
|
+
if @mutations.empty?
|
|
26
|
+
puts Rainbow("\n✓ No mutations were generated for this file; nothing to test.").green
|
|
27
|
+
return true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
return false unless shadow_environment_reliable?
|
|
31
|
+
|
|
32
|
+
run_mutations
|
|
33
|
+
generate_reports
|
|
34
|
+
return report_interruption if interrupted?
|
|
35
|
+
|
|
36
|
+
check_threshold
|
|
37
|
+
ensure
|
|
38
|
+
@mutation_runner&.cleanup_shadow_workspaces
|
|
39
|
+
ForkRunner.shutdown_all
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def interrupted?
|
|
43
|
+
@config.fail_fast &&
|
|
44
|
+
@results.size < @mutations.size &&
|
|
45
|
+
@results.any? { |result| result[:status] == :survived }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def mutation_score
|
|
49
|
+
Reporters::BaseReporter.score(@results)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def threshold_met?
|
|
53
|
+
return true unless @config.fail_on_threshold
|
|
54
|
+
|
|
55
|
+
mutation_score >= @config.minimum_score
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def print_header
|
|
61
|
+
puts Rainbow('=' * 80).bright
|
|
62
|
+
puts Rainbow("🧬 MutationTester v#{MutationTester::VERSION}").bright.cyan
|
|
63
|
+
puts Rainbow('=' * 80).bright
|
|
64
|
+
puts "Source: #{@source_file}"
|
|
65
|
+
puts "Spec: #{@spec_file}"
|
|
66
|
+
puts Rainbow('=' * 80).bright
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def run_original_tests
|
|
70
|
+
puts Rainbow("\n🧪 Running original tests...").yellow
|
|
71
|
+
command = test_command
|
|
72
|
+
result = command.run(timeout: @config.baseline_timeout, capture: true)
|
|
73
|
+
|
|
74
|
+
if result.timed_out?
|
|
75
|
+
puts Rainbow("❌ Original tests exceeded the baseline deadline of #{@config.baseline_timeout}s and were terminated.").red
|
|
76
|
+
puts Rainbow(' Speed them up or raise config.baseline_timeout (nil disables the deadline).').red
|
|
77
|
+
return false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
unless result.passed?
|
|
81
|
+
replay_baseline_output(result.output)
|
|
82
|
+
puts Rainbow('❌ Original tests are failing. Fix them first!').red
|
|
83
|
+
puts Rainbow("\nDebug: The following command failed:").yellow
|
|
84
|
+
puts Rainbow(" #{command.command}").cyan
|
|
85
|
+
return false
|
|
86
|
+
end
|
|
87
|
+
puts Rainbow('✓ Original tests passed').green
|
|
88
|
+
true
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def replay_baseline_output(output)
|
|
92
|
+
return if output.nil? || output.strip.empty?
|
|
93
|
+
|
|
94
|
+
puts Rainbow("\n🔴 Original test output:").red
|
|
95
|
+
puts output
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def generate_mutations
|
|
99
|
+
puts Rainbow("\n📝 Generating mutations...").yellow
|
|
100
|
+
|
|
101
|
+
ast = Parser::CurrentRuby.parse(@original_content)
|
|
102
|
+
mutator = Mutator.new(@source_file, @config)
|
|
103
|
+
@mutations = mutator.generate_mutations(ast)
|
|
104
|
+
|
|
105
|
+
puts Rainbow("✓ Generated #{@mutations.size} mutations").green
|
|
106
|
+
rescue Parser::SyntaxError => e
|
|
107
|
+
puts Rainbow("❌ Failed to parse source file: #{e.message}").red
|
|
108
|
+
@parse_failed = true
|
|
109
|
+
@mutations = []
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def shadow_environment_reliable?
|
|
113
|
+
return true unless @config.parallel_processes > 1
|
|
114
|
+
return true if mutation_runner.in_memory_first?
|
|
115
|
+
|
|
116
|
+
puts Rainbow("\n🩺 Verifying the shadow workspace with the unmutated source...").yellow
|
|
117
|
+
if mutation_runner.shadow_baseline_passes?
|
|
118
|
+
puts Rainbow('✓ Shadow workspace verified with the unmutated source').green
|
|
119
|
+
return true
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
puts Rainbow('❌ The unmutated source fails inside the shadow workspace; the shadow environment is unreliable.').red
|
|
123
|
+
puts Rainbow(' Every mutant would falsely die there, so the run is aborted instead of reporting a misleading score.').red
|
|
124
|
+
false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def run_mutations
|
|
128
|
+
return if @mutations.empty?
|
|
129
|
+
|
|
130
|
+
puts Rainbow("\n🔬 Running mutations in #{@config.parallel_processes} parallel job#{"s" if @config.parallel_processes > 1}...").yellow
|
|
131
|
+
|
|
132
|
+
progress_display = ProgressDisplay.new(@mutations.size, @config)
|
|
133
|
+
|
|
134
|
+
@results = mutation_runner.run(@mutations) do |mutation, index|
|
|
135
|
+
progress_display.update(mutation, index)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
progress_display.finish
|
|
139
|
+
puts Rainbow("✓ Completed #{@results.size} mutations").green
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def mutation_runner
|
|
143
|
+
@mutation_runner ||= MutationRunner.new(@source_file, @spec_file, @original_content, @config)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def generate_reports
|
|
147
|
+
puts Rainbow("\n📊 Generating reports...").yellow
|
|
148
|
+
FileUtils.mkdir_p(@config.output_dir)
|
|
149
|
+
|
|
150
|
+
@config.reporters.each do |reporter_type|
|
|
151
|
+
reporter = create_reporter(reporter_type)
|
|
152
|
+
reporter.generate if reporter
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def create_reporter(type)
|
|
157
|
+
case type
|
|
158
|
+
when :console
|
|
159
|
+
Reporters::ConsoleReporter.new(@results, @source_file, @spec_file, @config, interrupted: interrupted?)
|
|
160
|
+
when :html
|
|
161
|
+
Reporters::HtmlReporter.new(@results, @source_file, @spec_file, @config, interrupted: interrupted?)
|
|
162
|
+
when :json
|
|
163
|
+
Reporters::JsonReporter.new(@results, @source_file, @spec_file, @config, interrupted: interrupted?)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def test_command
|
|
168
|
+
TestCommand.new(
|
|
169
|
+
@spec_file,
|
|
170
|
+
use_bundle_exec: TestCommand.use_bundle_exec?(@source_file),
|
|
171
|
+
runner: @config.runner
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def report_interruption
|
|
176
|
+
puts Rainbow("\n🛑 Run interrupted by --fail-fast: a mutant survived after #{@results.size} of #{@mutations.size} mutations.").red
|
|
177
|
+
puts Rainbow(' Reports contain the results obtained up to the interruption.').red
|
|
178
|
+
false
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def check_threshold
|
|
182
|
+
score = mutation_score
|
|
183
|
+
|
|
184
|
+
unless threshold_met?
|
|
185
|
+
puts Rainbow("\n❌ Mutation score #{score}% is below threshold #{@config.minimum_score}%").red
|
|
186
|
+
return false
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
puts Rainbow("\n✓ Mutation score: #{score}%").green
|
|
190
|
+
true
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'rspec/core'
|
|
5
|
+
require_relative '../in_memory_loader'
|
|
6
|
+
|
|
7
|
+
control = $stdout.dup
|
|
8
|
+
control.sync = true
|
|
9
|
+
STDOUT.reopen(File::NULL)
|
|
10
|
+
|
|
11
|
+
kill_group = lambda do |pid|
|
|
12
|
+
begin
|
|
13
|
+
Process.kill('KILL', -pid)
|
|
14
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
monotonic = lambda { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
19
|
+
|
|
20
|
+
current_child = nil
|
|
21
|
+
preloaded = nil
|
|
22
|
+
|
|
23
|
+
%w[TERM INT].each do |signal|
|
|
24
|
+
trap(signal) do
|
|
25
|
+
kill_group.call(current_child) if current_child
|
|
26
|
+
trap(signal, 'DEFAULT')
|
|
27
|
+
Process.kill(signal, Process.pid)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
supervise_child = lambda do |job, out, child_body|
|
|
32
|
+
result_reader, result_writer = IO.pipe
|
|
33
|
+
|
|
34
|
+
child = fork do
|
|
35
|
+
trap('TERM', 'DEFAULT')
|
|
36
|
+
trap('INT', 'DEFAULT')
|
|
37
|
+
out.close
|
|
38
|
+
result_reader.close
|
|
39
|
+
begin
|
|
40
|
+
Process.setpgid(0, 0)
|
|
41
|
+
rescue Errno::EACCES, Errno::EPERM
|
|
42
|
+
end
|
|
43
|
+
Dir.chdir(job['chdir']) if job['chdir']
|
|
44
|
+
sink = File.open(job['log'] || File::NULL, 'w')
|
|
45
|
+
sink.sync = true
|
|
46
|
+
STDOUT.reopen(sink)
|
|
47
|
+
STDERR.reopen(sink)
|
|
48
|
+
result_writer.puts(child_body.call)
|
|
49
|
+
result_writer.close
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
result_writer.close
|
|
53
|
+
begin
|
|
54
|
+
Process.setpgid(child, child)
|
|
55
|
+
rescue Errno::EACCES, Errno::EPERM, Errno::ESRCH
|
|
56
|
+
end
|
|
57
|
+
current_child = child
|
|
58
|
+
out.puts(JSON.generate('event' => 'started', 'pid' => child))
|
|
59
|
+
|
|
60
|
+
deadline = job['timeout'] ? monotonic.call + job['timeout'] : nil
|
|
61
|
+
reaped = nil
|
|
62
|
+
timed_out = false
|
|
63
|
+
payload_ready = false
|
|
64
|
+
|
|
65
|
+
loop do
|
|
66
|
+
_pid, reaped = Process.waitpid2(child, Process::WNOHANG)
|
|
67
|
+
break if reaped
|
|
68
|
+
|
|
69
|
+
if deadline && monotonic.call >= deadline
|
|
70
|
+
timed_out = true
|
|
71
|
+
kill_group.call(child)
|
|
72
|
+
begin
|
|
73
|
+
Process.waitpid(child)
|
|
74
|
+
rescue Errno::ECHILD
|
|
75
|
+
end
|
|
76
|
+
break
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
if payload_ready
|
|
80
|
+
sleep(0.002)
|
|
81
|
+
else
|
|
82
|
+
remaining = deadline ? deadline - monotonic.call : nil
|
|
83
|
+
wait = remaining ? remaining.clamp(0, 0.05) : 0.05
|
|
84
|
+
payload_ready = !IO.select([result_reader], nil, nil, wait).nil?
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
current_child = nil
|
|
89
|
+
payload = result_reader.read
|
|
90
|
+
result_reader.close
|
|
91
|
+
[timed_out, payload, reaped]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
run_job = lambda do |job, out|
|
|
95
|
+
timed_out, payload, reaped = supervise_child.call(job, out, lambda do
|
|
96
|
+
RSpec::Core::Runner.run([job['spec'], *Array(job['args'])], STDERR, STDOUT).to_i
|
|
97
|
+
end)
|
|
98
|
+
|
|
99
|
+
status =
|
|
100
|
+
if timed_out
|
|
101
|
+
'timeout'
|
|
102
|
+
elsif payload[/-?\d+/] == '0' && reaped.success?
|
|
103
|
+
'pass'
|
|
104
|
+
else
|
|
105
|
+
'fail'
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
out.puts(JSON.generate('event' => 'result', 'status' => status))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
preload_specs = lambda do |request, out|
|
|
112
|
+
begin
|
|
113
|
+
Dir.chdir(request['chdir']) if request['chdir']
|
|
114
|
+
sink = File.open(File::NULL, 'w')
|
|
115
|
+
runner = RSpec::Core::Runner.new(RSpec::Core::ConfigurationOptions.new([request['spec']]))
|
|
116
|
+
runner.setup(sink, sink)
|
|
117
|
+
preloaded = runner
|
|
118
|
+
out.puts(JSON.generate('event' => 'preloaded', 'status' => 'ok'))
|
|
119
|
+
rescue ScriptError, StandardError => e
|
|
120
|
+
out.puts(JSON.generate('event' => 'preloaded', 'status' => 'error', 'message' => "#{e.class}: #{e.message}"))
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
run_in_memory_job = lambda do |job, out|
|
|
125
|
+
request = job['in_memory']
|
|
126
|
+
|
|
127
|
+
unless preloaded
|
|
128
|
+
out.puts(JSON.generate('event' => 'result', 'status' => 'error', 'message' => 'the worker has no preloaded spec environment'))
|
|
129
|
+
next
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
timed_out, payload, reaped = supervise_child.call(job, out, lambda do
|
|
133
|
+
begin
|
|
134
|
+
MutationTester::InMemoryLoader.apply(request['source'], request['path'])
|
|
135
|
+
JSON.generate('code' => preloaded.run_specs(RSpec.world.ordered_example_groups).to_i)
|
|
136
|
+
rescue ScriptError, StandardError => e
|
|
137
|
+
JSON.generate('error' => "#{e.class}: #{e.message}")
|
|
138
|
+
end
|
|
139
|
+
end)
|
|
140
|
+
|
|
141
|
+
outcome = begin
|
|
142
|
+
JSON.parse(payload)
|
|
143
|
+
rescue JSON::ParserError
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
event =
|
|
148
|
+
if timed_out
|
|
149
|
+
{ 'event' => 'result', 'status' => 'timeout' }
|
|
150
|
+
elsif outcome.is_a?(Hash) && outcome['error']
|
|
151
|
+
{ 'event' => 'result', 'status' => 'error', 'message' => outcome['error'] }
|
|
152
|
+
elsif outcome.is_a?(Hash) && outcome['code'] == 0 && reaped.success?
|
|
153
|
+
{ 'event' => 'result', 'status' => 'pass' }
|
|
154
|
+
else
|
|
155
|
+
{ 'event' => 'result', 'status' => 'fail' }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
out.puts(JSON.generate(event))
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
serve = nil
|
|
162
|
+
|
|
163
|
+
spawn_clone = lambda do |request, out|
|
|
164
|
+
child = fork do
|
|
165
|
+
guard = Thread.new do
|
|
166
|
+
sleep 15
|
|
167
|
+
Process.kill('KILL', Process.pid)
|
|
168
|
+
end
|
|
169
|
+
begin
|
|
170
|
+
Process.setpgid(0, 0)
|
|
171
|
+
rescue Errno::EACCES, Errno::EPERM
|
|
172
|
+
end
|
|
173
|
+
input = File.open(request['job'], 'r')
|
|
174
|
+
clone_out = File.open(request['events'], 'w')
|
|
175
|
+
guard.kill
|
|
176
|
+
clone_out.sync = true
|
|
177
|
+
out.close
|
|
178
|
+
STDIN.reopen(File::NULL)
|
|
179
|
+
request['env']&.each { |name, value| ENV[name] = value }
|
|
180
|
+
serve.call(input, clone_out)
|
|
181
|
+
end
|
|
182
|
+
Process.detach(child)
|
|
183
|
+
out.puts(JSON.generate('event' => 'cloned', 'pid' => child))
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
serve = lambda do |input, out|
|
|
187
|
+
out.puts(JSON.generate('event' => 'ready'))
|
|
188
|
+
input.each_line do |line|
|
|
189
|
+
job = JSON.parse(line)
|
|
190
|
+
if job['clone']
|
|
191
|
+
spawn_clone.call(job['clone'], out)
|
|
192
|
+
elsif job['preload']
|
|
193
|
+
preload_specs.call(job['preload'], out)
|
|
194
|
+
elsif job['in_memory']
|
|
195
|
+
run_in_memory_job.call(job, out)
|
|
196
|
+
else
|
|
197
|
+
run_job.call(job, out)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
serve.call($stdin, control)
|