moots 0.1.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 +34 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +312 -0
- data/docs/isolation.md +89 -0
- data/docs/reporting.md +70 -0
- data/exe/moots +6 -0
- data/lib/moots/cli.rb +75 -0
- data/lib/moots/configuration.rb +86 -0
- data/lib/moots/environment.rb +23 -0
- data/lib/moots/error.rb +6 -0
- data/lib/moots/mutation.rb +42 -0
- data/lib/moots/mutation_generator.rb +60 -0
- data/lib/moots/reporter.rb +104 -0
- data/lib/moots/test_result.rb +17 -0
- data/lib/moots/test_runner.rb +60 -0
- data/lib/moots/version.rb +5 -0
- data/lib/moots/worker_pool.rb +76 -0
- data/lib/moots/workspace.rb +137 -0
- data/lib/moots.rb +111 -0
- data/sig/moots.rbs +4 -0
- metadata +100 -0
data/lib/moots/cli.rb
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "../moots"
|
|
5
|
+
require_relative "reporter"
|
|
6
|
+
|
|
7
|
+
module Moots
|
|
8
|
+
# Command-line entry point. Survivors and empty runs have a failing exit status.
|
|
9
|
+
class CLI
|
|
10
|
+
def self.run(argv = ARGV, out: $stdout, err: $stderr)
|
|
11
|
+
new(argv, out: out, err: err).run
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(argv, out:, err:)
|
|
15
|
+
@argv = argv
|
|
16
|
+
@out = out
|
|
17
|
+
@err = err
|
|
18
|
+
@path = ".moots.yml"
|
|
19
|
+
@explicit_path = false
|
|
20
|
+
@format = "text"
|
|
21
|
+
@overrides = {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def run
|
|
25
|
+
return 0 unless run_requested?
|
|
26
|
+
|
|
27
|
+
@config_path = @path if @explicit_path || File.exist?(@path)
|
|
28
|
+
config = @config_path ? Configuration.load(@config_path) : {}
|
|
29
|
+
results = Moots.run(config.merge(@overrides)) { |event| reporter.call(event) }
|
|
30
|
+
reporter.finish(results)
|
|
31
|
+
return 1 if results.empty?
|
|
32
|
+
|
|
33
|
+
results.all?(&:killed) ? 0 : 1
|
|
34
|
+
rescue Error, OptionParser::ParseError, SystemCallError => e
|
|
35
|
+
reporter.error(e.message)
|
|
36
|
+
2
|
|
37
|
+
rescue Interrupt
|
|
38
|
+
reporter.error("Interrupted")
|
|
39
|
+
130
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def reporter
|
|
45
|
+
@reporter ||= Reporter.new(format: @format, out: @out, err: @err, config_path: @config_path)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def run_requested?
|
|
49
|
+
parser = OptionParser.new do |options|
|
|
50
|
+
options.banner = "Usage: moots [--config PATH] [--format text|json] [--mutation ID] [--jobs N]"
|
|
51
|
+
options.on("-c", "--config PATH", "Configuration file (default: .moots.yml)") do |value|
|
|
52
|
+
@path = value
|
|
53
|
+
@explicit_path = true
|
|
54
|
+
end
|
|
55
|
+
options.on("--format FORMAT", %w[text json], "Output format (default: text)") { |value| @format = value }
|
|
56
|
+
options.on("--mutation ID", "Run one mutation by its reported ID") { |value| @overrides[:mutation] = value }
|
|
57
|
+
options.on("-j", "--jobs N", Integer, "Concurrent isolated workers (default: 1)") do |value|
|
|
58
|
+
@overrides[:jobs] = value
|
|
59
|
+
end
|
|
60
|
+
options.on("-v", "--version", "Print version") do
|
|
61
|
+
@out.puts(VERSION)
|
|
62
|
+
return false
|
|
63
|
+
end
|
|
64
|
+
options.on("-h", "--help", "Show help") do
|
|
65
|
+
@out.puts(options)
|
|
66
|
+
return false
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
remaining = parser.parse(@argv)
|
|
70
|
+
raise Error, "Unexpected arguments: #{remaining.join(" ")}" unless remaining.empty?
|
|
71
|
+
|
|
72
|
+
true
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require_relative "error"
|
|
5
|
+
|
|
6
|
+
module Moots
|
|
7
|
+
# Load safe YAML and validate the options shared by the CLI and Ruby API.
|
|
8
|
+
class Configuration
|
|
9
|
+
DEFAULTS = {
|
|
10
|
+
include: ["app/**/*.rb", "lib/**/*.rb"].freeze,
|
|
11
|
+
exclude: [].freeze,
|
|
12
|
+
test_command: "bundle exec rspec",
|
|
13
|
+
timeout: 60,
|
|
14
|
+
mutation: nil,
|
|
15
|
+
isolation: true,
|
|
16
|
+
jobs: 1,
|
|
17
|
+
workspace_exclude: ["tmp", "coverage", "pkg", ".rspec_status"].freeze
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def self.load(path)
|
|
21
|
+
new(YAML.safe_load_file(path)).to_h
|
|
22
|
+
rescue Psych::Exception, SystemCallError => e
|
|
23
|
+
raise Error, "Cannot load configuration #{path}: #{e.message}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(options = {})
|
|
27
|
+
raise Error, "Configuration must be a mapping" unless options.is_a?(Hash)
|
|
28
|
+
|
|
29
|
+
normalized = options.transform_keys(&:to_s)
|
|
30
|
+
unknown = normalized.keys - DEFAULTS.keys.map(&:to_s)
|
|
31
|
+
raise Error, "Unknown configuration keys: #{unknown.join(", ")}" unless unknown.empty?
|
|
32
|
+
|
|
33
|
+
@options = DEFAULTS.merge(normalized.transform_keys(&:to_sym))
|
|
34
|
+
validate_patterns(:include)
|
|
35
|
+
validate_patterns(:exclude)
|
|
36
|
+
validate_patterns(:workspace_exclude)
|
|
37
|
+
raise Error, "isolation must be true or false" unless [true, false].include?(@options[:isolation])
|
|
38
|
+
|
|
39
|
+
validate_jobs
|
|
40
|
+
validate_command
|
|
41
|
+
validate_mutation
|
|
42
|
+
timeout = @options[:timeout]
|
|
43
|
+
return if timeout.is_a?(Numeric) && timeout.real? && timeout.finite? && timeout.positive?
|
|
44
|
+
|
|
45
|
+
raise Error, "timeout must be a positive finite number of seconds"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_h
|
|
49
|
+
@options.dup
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def validate_jobs
|
|
55
|
+
jobs = @options[:jobs]
|
|
56
|
+
raise Error, "jobs must be a positive integer" unless jobs.is_a?(Integer) && jobs.positive?
|
|
57
|
+
raise Error, "jobs greater than 1 require isolation: true" if jobs > 1 && !@options[:isolation]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def validate_patterns(key)
|
|
61
|
+
patterns = @options[key]
|
|
62
|
+
return if patterns.is_a?(Array) && patterns.all? { |pattern| valid_string?(pattern) }
|
|
63
|
+
|
|
64
|
+
raise Error, "#{key} must be an array of non-empty glob strings"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def validate_command
|
|
68
|
+
command = @options[:test_command]
|
|
69
|
+
return if valid_string?(command)
|
|
70
|
+
return if command.is_a?(Array) && !command.empty? && command.all? { |part| valid_string?(part) }
|
|
71
|
+
|
|
72
|
+
raise Error, "test_command must be a non-empty string or argument array"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def valid_string?(value)
|
|
76
|
+
value.is_a?(String) && !value.strip.empty? && !value.include?("\0")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate_mutation
|
|
80
|
+
id = @options[:mutation]
|
|
81
|
+
return if id.nil? || (id.is_a?(String) && /\A[0-9a-f]{16}\z/.match?(id))
|
|
82
|
+
|
|
83
|
+
raise Error, "mutation must be a 16-character lowercase hexadecimal ID or null"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moots
|
|
4
|
+
# Relocate known Bundler and Ruby path variables without editing the parent environment.
|
|
5
|
+
module Environment
|
|
6
|
+
PATH_VARIABLES = %w[BUNDLE_GEMFILE BUNDLE_APP_CONFIG BUNDLE_BIN_PATH BUNDLE_PATH GEM_HOME].freeze
|
|
7
|
+
PATH_LISTS = %w[GEM_PATH RUBYLIB PATH].freeze
|
|
8
|
+
|
|
9
|
+
def self.rebase(source, &mapper)
|
|
10
|
+
(PATH_VARIABLES + PATH_LISTS).each_with_object({}) do |name, overrides|
|
|
11
|
+
[name, "BUNDLER_ORIG_#{name}"].each do |key|
|
|
12
|
+
next unless source.key?(key)
|
|
13
|
+
|
|
14
|
+
overrides[key] = if PATH_LISTS.include?(name)
|
|
15
|
+
source.fetch(key).split(File::PATH_SEPARATOR, -1).map(&mapper).join(File::PATH_SEPARATOR)
|
|
16
|
+
else
|
|
17
|
+
mapper.call(source.fetch(key))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/moots/error.rb
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "error"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "json"
|
|
6
|
+
require "pathname"
|
|
7
|
+
|
|
8
|
+
module Moots
|
|
9
|
+
# A single expression replacement, located by byte offset in the original source.
|
|
10
|
+
class Mutation
|
|
11
|
+
attr_reader :original_code, :mutated_code, :line_number, :file_path, :start_offset
|
|
12
|
+
|
|
13
|
+
def initialize(original_code:, mutated_code:, line_number:, file_path:, start_offset:)
|
|
14
|
+
@original_code = original_code
|
|
15
|
+
@mutated_code = mutated_code
|
|
16
|
+
@line_number = line_number
|
|
17
|
+
@file_path = file_path
|
|
18
|
+
@start_offset = start_offset
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def relative_path(root: Dir.pwd)
|
|
22
|
+
Pathname.new(File.expand_path(file_path)).relative_path_from(Pathname.new(File.expand_path(root))).to_s
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Stable across equivalent checkouts; source bytes distinguish each replacement.
|
|
26
|
+
def id(root: Dir.pwd)
|
|
27
|
+
identity = [relative_path(root: root), start_offset, original_code.b.unpack1("H*"), mutated_code.b.unpack1("H*")]
|
|
28
|
+
Digest::SHA256.hexdigest(JSON.generate(identity))[0, 16]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Return a changed copy without touching the input or replacing other occurrences.
|
|
32
|
+
def apply(code)
|
|
33
|
+
bytes = code.b
|
|
34
|
+
unless bytes.byteslice(start_offset, original_code.bytesize) == original_code.b
|
|
35
|
+
raise Error, "Source no longer matches mutation at #{file_path}:#{line_number}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
bytes[start_offset, original_code.bytesize] = mutated_code.b
|
|
39
|
+
bytes.force_encoding(code.encoding)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "mutation"
|
|
5
|
+
|
|
6
|
+
module Moots
|
|
7
|
+
# Generate one mutation per supported operator using Prism's byte locations.
|
|
8
|
+
class MutationGenerator
|
|
9
|
+
ARITHMETIC_OPERATORS = { "+" => "-", "-" => "+", "*" => "/", "/" => "*" }.freeze
|
|
10
|
+
COMPARISON_OPERATORS = { "==" => "!=", "!=" => "==", ">" => ">=", ">=" => ">", "<" => "<=", "<=" => "<" }.freeze
|
|
11
|
+
CALL_OPERATORS = ARITHMETIC_OPERATORS.merge(COMPARISON_OPERATORS).freeze
|
|
12
|
+
BOOLEAN_OPERATORS = { "&&" => "||", "||" => "&&", "and" => "or", "or" => "and" }.freeze
|
|
13
|
+
|
|
14
|
+
def generate_mutations(code, file_path: "(string)")
|
|
15
|
+
parsed = Prism.parse(code)
|
|
16
|
+
unless parsed.success?
|
|
17
|
+
error = parsed.errors.first
|
|
18
|
+
raise Error, "#{file_path}:#{error.location.start_line}: #{error.message}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
mutations = []
|
|
22
|
+
nodes = [parsed.value]
|
|
23
|
+
until nodes.empty?
|
|
24
|
+
node = nodes.pop
|
|
25
|
+
mutation = mutation_for(node, file_path)
|
|
26
|
+
mutations << mutation if mutation
|
|
27
|
+
nodes.concat(node.compact_child_nodes.reverse)
|
|
28
|
+
end
|
|
29
|
+
mutations
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def mutation_for(node, file_path)
|
|
35
|
+
case node
|
|
36
|
+
when Prism::CallNode
|
|
37
|
+
replacement = CALL_OPERATORS[node.name.to_s]
|
|
38
|
+
return unless replacement
|
|
39
|
+
|
|
40
|
+
location = node.message_loc
|
|
41
|
+
when Prism::AndNode, Prism::OrNode
|
|
42
|
+
location = node.operator_loc
|
|
43
|
+
replacement = BOOLEAN_OPERATORS.fetch(location.slice)
|
|
44
|
+
else
|
|
45
|
+
return
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
expression = node.location
|
|
49
|
+
mutated = expression.slice.b
|
|
50
|
+
mutated[location.start_offset - expression.start_offset, location.length] = replacement
|
|
51
|
+
Mutation.new(
|
|
52
|
+
original_code: expression.slice,
|
|
53
|
+
mutated_code: mutated.force_encoding(expression.slice.encoding),
|
|
54
|
+
line_number: location.start_line,
|
|
55
|
+
file_path: file_path,
|
|
56
|
+
start_offset: expression.start_offset
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Moots
|
|
6
|
+
# Stream progress independently of the final, versioned machine-readable report.
|
|
7
|
+
class Reporter
|
|
8
|
+
def initialize(format:, out:, err:, config_path: nil, root: Dir.pwd)
|
|
9
|
+
@format = format
|
|
10
|
+
@out = out
|
|
11
|
+
@err = err
|
|
12
|
+
@progress = format == "json" ? err : out
|
|
13
|
+
@config_path = config_path
|
|
14
|
+
@root = root
|
|
15
|
+
@results = []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(event)
|
|
19
|
+
case event.fetch(:type)
|
|
20
|
+
when :workspace_started
|
|
21
|
+
@progress.puts("Preparing isolated working copy...")
|
|
22
|
+
when :baseline_started
|
|
23
|
+
@progress.puts("Running baseline tests...")
|
|
24
|
+
when :baseline_passed
|
|
25
|
+
@progress.puts("Baseline passed.")
|
|
26
|
+
when :mutation_started
|
|
27
|
+
@progress.puts("[#{event.fetch(:index)}/#{event.fetch(:total)}] Testing #{location(event.fetch(:mutation))}")
|
|
28
|
+
when :mutation_finished
|
|
29
|
+
result = event.fetch(:result)
|
|
30
|
+
@results[event.fetch(:index, @results.size)] = result
|
|
31
|
+
@progress.puts("#{result.killed ? "KILLED" : "SURVIVED"} #{location(result.mutation)}")
|
|
32
|
+
survivor(result.mutation) unless result.killed
|
|
33
|
+
else
|
|
34
|
+
raise ArgumentError, "Unknown progress event: #{event[:type]}"
|
|
35
|
+
end
|
|
36
|
+
@progress.flush
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def finish(results)
|
|
40
|
+
@results = results
|
|
41
|
+
if @format == "json"
|
|
42
|
+
@out.puts(JSON.generate(document("completed")))
|
|
43
|
+
else
|
|
44
|
+
counts = summary
|
|
45
|
+
@out.puts("#{counts[:mutations]} mutations, #{counts[:killed]} killed, #{counts[:survived]} survived")
|
|
46
|
+
end
|
|
47
|
+
@out.flush
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def error(message)
|
|
51
|
+
@err.puts("moots: #{text(message)}")
|
|
52
|
+
@err.flush
|
|
53
|
+
return unless @format == "json"
|
|
54
|
+
|
|
55
|
+
@out.puts(JSON.generate(document("error").merge(error: text(message))))
|
|
56
|
+
@out.flush
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def text(value)
|
|
62
|
+
value.dup.force_encoding(Encoding::UTF_8).scrub
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def location(mutation)
|
|
66
|
+
"#{mutation.id(root: @root)} #{text(mutation.relative_path(root: @root))}:#{mutation.line_number}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def rerun(mutation)
|
|
70
|
+
command = %w[bundle exec moots]
|
|
71
|
+
command.push("--config", @config_path) if @config_path
|
|
72
|
+
command + ["--mutation", mutation.id(root: @root)]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def survivor(mutation)
|
|
76
|
+
@progress.puts(" Expression diff:")
|
|
77
|
+
text(mutation.original_code).each_line { |line| @progress.puts(" - #{line.chomp}") }
|
|
78
|
+
text(mutation.mutated_code).each_line { |line| @progress.puts(" + #{line.chomp}") }
|
|
79
|
+
@progress.puts(" Rerun argv: #{JSON.generate(rerun(mutation))}")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def summary
|
|
83
|
+
completed = @results.compact
|
|
84
|
+
killed = completed.count(&:killed)
|
|
85
|
+
{ mutations: completed.size, killed: killed, survived: completed.size - killed }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def document(status)
|
|
89
|
+
serialized = @results.compact.map { |result| serialize(result) }
|
|
90
|
+
{ schema_version: 1, status: status, summary: summary, results: serialized }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def serialize(result)
|
|
94
|
+
mutation = result.mutation
|
|
95
|
+
{
|
|
96
|
+
id: mutation.id(root: @root), file: text(mutation.relative_path(root: @root)),
|
|
97
|
+
line: mutation.line_number, start_offset: mutation.start_offset,
|
|
98
|
+
original: text(mutation.original_code), mutated: text(mutation.mutated_code),
|
|
99
|
+
status: result.killed ? "killed" : "survived", test_output: text(result.test_result.output),
|
|
100
|
+
rerun: rerun(mutation)
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moots
|
|
4
|
+
# The exit status and combined output of one test command.
|
|
5
|
+
class TestResult
|
|
6
|
+
attr_reader :output
|
|
7
|
+
|
|
8
|
+
def initialize(success:, output:)
|
|
9
|
+
@success = success
|
|
10
|
+
@output = output
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def success?
|
|
14
|
+
@success
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "timeout"
|
|
5
|
+
require_relative "error"
|
|
6
|
+
require_relative "test_result"
|
|
7
|
+
|
|
8
|
+
module Moots
|
|
9
|
+
# Execute suites with a deadline and concurrently drain both output streams.
|
|
10
|
+
class TestRunner
|
|
11
|
+
def initialize(chdir: Dir.pwd, env: {})
|
|
12
|
+
@directory = chdir
|
|
13
|
+
@environment = env
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run_tests(command, timeout: 60)
|
|
17
|
+
options = Gem.win_platform? ? { new_pgroup: true } : { pgroup: true }
|
|
18
|
+
Open3.popen3(@environment, *Array(command), chdir: @directory, **options) do |stdin, stdout, stderr, process|
|
|
19
|
+
stdin.close
|
|
20
|
+
readers = [stdout, stderr].map { |stream| Thread.new { stream.read } }
|
|
21
|
+
completed = false
|
|
22
|
+
begin
|
|
23
|
+
result = Timeout.timeout(timeout) do
|
|
24
|
+
status = process.value
|
|
25
|
+
output = readers.map(&:value).reject(&:empty?).join("\n").strip
|
|
26
|
+
raise Error, "Test command terminated by signal #{status.termsig}" if status.signaled?
|
|
27
|
+
if [126, 127].include?(status.exitstatus)
|
|
28
|
+
raise Error, "Test command could not execute (exit #{status.exitstatus}): #{output}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
TestResult.new(success: status.success?, output: output)
|
|
32
|
+
end
|
|
33
|
+
completed = true
|
|
34
|
+
result
|
|
35
|
+
ensure
|
|
36
|
+
terminate(process.pid) unless completed
|
|
37
|
+
readers.each(&:kill) unless completed
|
|
38
|
+
readers.each(&:join)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
rescue Timeout::Error
|
|
42
|
+
raise Error, "Test command timed out after #{timeout} seconds"
|
|
43
|
+
rescue SystemCallError => e
|
|
44
|
+
raise Error, "Cannot execute test command: #{e.message}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def terminate(pid)
|
|
50
|
+
if Gem.win_platform?
|
|
51
|
+
system("taskkill", "/PID", pid.to_s, "/T", "/F", out: File::NULL, err: File::NULL)
|
|
52
|
+
else
|
|
53
|
+
Process.kill("KILL", -pid)
|
|
54
|
+
end
|
|
55
|
+
rescue Errno::ESRCH
|
|
56
|
+
# The process may have exited between the timeout and the kill.
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moots
|
|
4
|
+
# Run work concurrently, but deliver callbacks and exceptions on the calling thread.
|
|
5
|
+
class WorkerPool
|
|
6
|
+
def initialize(workers, before:, after:)
|
|
7
|
+
@workers = workers
|
|
8
|
+
@before = before
|
|
9
|
+
@after = after
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def map(items, &operation)
|
|
13
|
+
pending = Queue.new
|
|
14
|
+
items.each_with_index { |item, index| pending << [item, index] }
|
|
15
|
+
events = Queue.new
|
|
16
|
+
threads = []
|
|
17
|
+
begin
|
|
18
|
+
@workers.each { |worker| threads << start_worker(worker, pending, events, operation) }
|
|
19
|
+
results = collect(items.size, events)
|
|
20
|
+
threads.each(&:join)
|
|
21
|
+
results
|
|
22
|
+
ensure
|
|
23
|
+
threads.each(&:kill)
|
|
24
|
+
threads.each(&:join)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def take(queue)
|
|
31
|
+
queue.pop(true)
|
|
32
|
+
rescue ThreadError
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def start_worker(worker, pending, events, operation)
|
|
37
|
+
Thread.new do
|
|
38
|
+
while (entry = take(pending))
|
|
39
|
+
item, index = entry
|
|
40
|
+
ready = Queue.new
|
|
41
|
+
events << [:started, index, item, ready]
|
|
42
|
+
ready.pop
|
|
43
|
+
result = operation.call(worker, item)
|
|
44
|
+
events << [:finished, index, result]
|
|
45
|
+
end
|
|
46
|
+
# Relay every worker exception, including Interrupt, so the caller never waits forever.
|
|
47
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
48
|
+
events << [:failed, e]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def collect(total, events)
|
|
53
|
+
results = Array.new(total)
|
|
54
|
+
total.times do
|
|
55
|
+
index, value = next_result(events)
|
|
56
|
+
results[index] = value
|
|
57
|
+
@after.call(value, index)
|
|
58
|
+
end
|
|
59
|
+
results
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def next_result(events)
|
|
63
|
+
loop do
|
|
64
|
+
type, index, value, ready = events.pop
|
|
65
|
+
if type == :started
|
|
66
|
+
@before.call(value, index)
|
|
67
|
+
ready << true
|
|
68
|
+
elsif type == :failed
|
|
69
|
+
raise index
|
|
70
|
+
else
|
|
71
|
+
return [index, value]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|