ace-test-runner 0.28.0 → 0.28.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/.ace-defaults/test-runner/config.yml +2 -0
- data/CHANGELOG.md +13 -0
- data/lib/ace/test_runner/atoms/command_builder.rb +60 -86
- data/lib/ace/test_runner/atoms/line_number_resolver.rb +70 -63
- data/lib/ace/test_runner/atoms/result_parser.rb +1 -1
- data/lib/ace/test_runner/cli/commands/test.rb +1 -0
- data/lib/ace/test_runner/models/test_selection_plan.rb +44 -0
- data/lib/ace/test_runner/molecules/cli_argument_parser.rb +9 -1
- data/lib/ace/test_runner/molecules/in_process_runner.rb +22 -33
- data/lib/ace/test_runner/molecules/pattern_resolver.rb +8 -5
- data/lib/ace/test_runner/molecules/selection_resolver.rb +45 -0
- data/lib/ace/test_runner/molecules/selection_verifier.rb +78 -0
- data/lib/ace/test_runner/molecules/smart_test_executor.rb +4 -0
- data/lib/ace/test_runner/molecules/test_executor.rb +10 -4
- data/lib/ace/test_runner/organisms/sequential_target_executor.rb +1 -0
- data/lib/ace/test_runner/organisms/test_orchestrator.rb +3 -0
- data/lib/ace/test_runner/version.rb +1 -1
- metadata +19 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8fc2c5132ba01f77ad703fc44426139e83ae2f930eb95a3ab26293f237bd13c7
|
|
4
|
+
data.tar.gz: b998e891eeecc4dd6ff6be3cdfcfb15c5d14a17333b306febecbd1db4dee4532
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d70486feda75375aaf8e5057adc1c6b9a10cf2692a7b2b7cd5d337c6375ba53a894c353de62b3178ca33e95460a208b367a9c283256b9ae8ee8127891f18e2bb
|
|
7
|
+
data.tar.gz: 2545709874b877010ad45bb538d05d03167b0a994a81119503a85c9f8666a13c689a2b971c738d8107d9f312785ec5a329de40718ac796baebb975f13f8421fa
|
|
@@ -26,6 +26,7 @@ patterns:
|
|
|
26
26
|
core: "test/{fast/,}core/**/*_test.rb"
|
|
27
27
|
lifecycle: "test/{fast/,}lifecycle/**/*_test.rb"
|
|
28
28
|
edge: "test/{fast/,}edge/**/*_test.rb"
|
|
29
|
+
fast_tests: "test/fast/**/*_test.rb"
|
|
29
30
|
feat_tests: "test/feat/**/*_test.rb"
|
|
30
31
|
|
|
31
32
|
# Test targets that combine multiple patterns
|
|
@@ -45,6 +46,7 @@ targets:
|
|
|
45
46
|
- contract
|
|
46
47
|
- core
|
|
47
48
|
- lifecycle
|
|
49
|
+
- fast_tests
|
|
48
50
|
feat:
|
|
49
51
|
- feat_tests
|
|
50
52
|
- edge
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.28.1] - 2026-10-08
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Reject missing explicit Ruby test files before loading tests or running selectors. Preserve exact unambiguous file-line selection.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Refuse invalid or ambiguous file-line selections and execute exact class/method identities in direct and subprocess modes. Reject mixed whole-file/line selections before loading tests.
|
|
19
|
+
|
|
20
|
+
- Include uncategorized fast test directories in default fast/all selection without executing overlapping matches twice or widening explicit target overrides.
|
|
21
|
+
- Exclude undispatched groups from sequential execution reports after target fail-fast.
|
|
22
|
+
|
|
10
23
|
## [0.28.0] - 2026-10-05
|
|
11
24
|
|
|
12
25
|
### Fixed
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "line_number_resolver"
|
|
4
|
+
require_relative "../molecules/selection_resolver"
|
|
5
|
+
require "shellwords"
|
|
4
6
|
|
|
5
7
|
module Ace
|
|
6
8
|
module TestRunner
|
|
@@ -13,6 +15,8 @@ module Ace
|
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def build_test_command(files, options = {})
|
|
18
|
+
selection = options[:selection_plan] || Molecules::SelectionResolver.resolve(Array(files))
|
|
19
|
+
return exact_selection_command(selection, options) if selection.qualified?
|
|
16
20
|
cmd_parts = []
|
|
17
21
|
|
|
18
22
|
# Use bundler if available and requested
|
|
@@ -29,39 +33,28 @@ module Ace
|
|
|
29
33
|
|
|
30
34
|
# Add the test files
|
|
31
35
|
if files.is_a?(Array)
|
|
32
|
-
#
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
#
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
script_parts << "exit_code = Minitest.autorun"
|
|
55
|
-
script_parts << "exit(exit_code)"
|
|
56
|
-
|
|
57
|
-
# Execute the requires and then run Minitest
|
|
58
|
-
cmd_parts << "-e"
|
|
59
|
-
# Use double quotes to wrap the entire script
|
|
60
|
-
cmd_parts << "\"#{script_parts.join("; ")}\""
|
|
61
|
-
end
|
|
62
|
-
elsif files.match?(/:\d+$/)
|
|
63
|
-
# Check if single file has line number
|
|
64
|
-
build_line_number_command(cmd_parts, [files], options)
|
|
36
|
+
# Build a Ruby script that requires each file and fails on LoadError
|
|
37
|
+
requires_script = files.map do |f|
|
|
38
|
+
# Add ./ prefix if it's a relative path without one
|
|
39
|
+
path = f.start_with?("/", "./") ? f : "./#{f}"
|
|
40
|
+
# Escape the path for shell safety
|
|
41
|
+
escaped_path = path.gsub("'", "\\\\'")
|
|
42
|
+
"begin; require '#{escaped_path}'; rescue LoadError => e; STDERR.puts \\\"Failed to load #{escaped_path}: \\\" + e.message; exit(1); end"
|
|
43
|
+
end.join("; ")
|
|
44
|
+
|
|
45
|
+
# Build the script parts
|
|
46
|
+
script_parts = []
|
|
47
|
+
# Inject ARGV with --verbose for Minitest if profile is requested
|
|
48
|
+
# (Ruby's --verbose flag only sets $VERBOSE, doesn't enable Minitest verbose mode)
|
|
49
|
+
script_parts << "ARGV.replace(['--verbose'])" if options[:profile]
|
|
50
|
+
script_parts << requires_script
|
|
51
|
+
script_parts << "exit_code = Minitest.autorun"
|
|
52
|
+
script_parts << "exit(exit_code)"
|
|
53
|
+
|
|
54
|
+
# Execute the requires and then run Minitest
|
|
55
|
+
cmd_parts << "-e"
|
|
56
|
+
# Use double quotes to wrap the entire script
|
|
57
|
+
cmd_parts << "\"#{script_parts.join("; ")}\""
|
|
65
58
|
elsif options[:profile]
|
|
66
59
|
# Single file without line number
|
|
67
60
|
cmd_parts << "-e"
|
|
@@ -100,60 +93,41 @@ module Ace
|
|
|
100
93
|
|
|
101
94
|
private
|
|
102
95
|
|
|
103
|
-
def
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
96
|
+
def exact_selection_command(plan, options)
|
|
97
|
+
argv = []
|
|
98
|
+
argv.concat(["bundle", "exec"]) if @bundler && bundler_available?
|
|
99
|
+
argv.concat(Shellwords.split(@ruby_command))
|
|
100
|
+
argv << "-Ilib:test" unless options[:no_load_path]
|
|
101
|
+
model = File.expand_path("../models/test_selection_plan", __dir__)
|
|
102
|
+
verifier = File.expand_path("../molecules/selection_verifier", __dir__)
|
|
103
|
+
script = <<~RUBY
|
|
104
|
+
begin
|
|
105
|
+
require #{model.inspect}
|
|
106
|
+
require #{verifier.inspect}
|
|
107
|
+
plan = Ace::TestRunner::Models::TestSelectionPlan.new(
|
|
108
|
+
files: #{plan.files.inspect}, identities: #{plan.identities.inspect},
|
|
109
|
+
source_digests: #{plan.source_digests.inspect})
|
|
110
|
+
verifier = Ace::TestRunner::Molecules::SelectionVerifier
|
|
111
|
+
verifier.verify_sources!(plan)
|
|
112
|
+
require "minitest/autorun"
|
|
113
|
+
plan.files.each { |file| require file }
|
|
114
|
+
pattern = verifier.verify_loaded!(plan)
|
|
115
|
+
args = ["--name", "/" + pattern + "/"]
|
|
116
|
+
args << "--verbose" if #{!!options[:profile]}
|
|
117
|
+
Minitest::Reporters.use! Minitest::Reporters::DefaultReporter.new
|
|
118
|
+
success = verifier.verify_execution!(plan) { Minitest.run(args) }
|
|
119
|
+
STDOUT.flush
|
|
120
|
+
STDERR.flush
|
|
121
|
+
exit!(success ? 0 : 1)
|
|
122
|
+
rescue Exception => error
|
|
123
|
+
STDERR.puts(error.message)
|
|
124
|
+
STDERR.flush
|
|
125
|
+
exit!(1)
|
|
130
126
|
end
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
# Require all files
|
|
137
|
-
script_parts << file_requires.join("; ")
|
|
138
|
-
|
|
139
|
-
# Set up ARGV with --name filter and --verbose if needed
|
|
140
|
-
argv_args = []
|
|
141
|
-
argv_args << "--verbose" if options[:profile]
|
|
142
|
-
if test_names.any?
|
|
143
|
-
# Create a regex pattern that matches any of the test names
|
|
144
|
-
pattern = test_names.map { |name| Regexp.escape(name) }.join("|")
|
|
145
|
-
argv_args << "--name"
|
|
146
|
-
argv_args << "/#{pattern}/"
|
|
147
|
-
end
|
|
148
|
-
script_parts << "ARGV.replace([#{argv_args.map { |a| "'#{a}'" }.join(", ")}])" if argv_args.any?
|
|
149
|
-
|
|
150
|
-
# Run Minitest
|
|
151
|
-
script_parts << "exit_code = Minitest.autorun"
|
|
152
|
-
script_parts << "exit(exit_code)"
|
|
153
|
-
|
|
154
|
-
# Add to command
|
|
155
|
-
cmd_parts << "-e"
|
|
156
|
-
cmd_parts << "\"#{script_parts.join("; ")}\""
|
|
127
|
+
RUBY
|
|
128
|
+
argv.concat(["-e", script])
|
|
129
|
+
argv.concat(Array(options[:args])) if options[:args]
|
|
130
|
+
argv
|
|
157
131
|
end
|
|
158
132
|
|
|
159
133
|
def bundler_available?
|
|
@@ -1,85 +1,92 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
3
5
|
module Ace
|
|
4
6
|
module TestRunner
|
|
5
7
|
module Atoms
|
|
6
|
-
# Resolves line numbers to test method names
|
|
7
8
|
module LineNumberResolver
|
|
9
|
+
class SelectionError < ArgumentError; end
|
|
8
10
|
module_function
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
test_methods = extract_test_methods(lines)
|
|
20
|
-
|
|
21
|
-
# Find the test that contains the specified line
|
|
22
|
-
test_methods.find do |test|
|
|
23
|
-
line_number >= test[:start_line] && line_number <= test[:end_line]
|
|
24
|
-
end&.fetch(:name)
|
|
12
|
+
def parse_file_with_line(selector)
|
|
13
|
+
if (match = /\A(.+\.rb):(.+)\z/.match(selector))
|
|
14
|
+
unless match[2].match?(/\A[1-9]\d*\z/)
|
|
15
|
+
raise SelectionError, "Invalid test selector: #{selector}"
|
|
16
|
+
end
|
|
17
|
+
{file: match[1], line: Integer(match[2], 10)}
|
|
18
|
+
else
|
|
19
|
+
{file: selector, line: nil}
|
|
20
|
+
end
|
|
25
21
|
end
|
|
26
22
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
current_test = nil
|
|
31
|
-
|
|
32
|
-
lines.each_with_index do |line, index|
|
|
33
|
-
line_number = index + 1
|
|
34
|
-
|
|
35
|
-
# Match test method definitions: def test_something or test "something"
|
|
36
|
-
if line =~ /^\s*(def\s+(test_\w+)|test\s+["'](.+)["']\s+do)/
|
|
37
|
-
test_name = $2 || $3 # Either def test_name or test "name"
|
|
38
|
-
|
|
39
|
-
# Convert test "name" to test_name format for minitest --name option
|
|
40
|
-
test_name = test_name.gsub(/\s+/, "_") if test_name && test_name.include?(" ")
|
|
41
|
-
|
|
42
|
-
# Close previous test if any
|
|
43
|
-
if current_test
|
|
44
|
-
current_test[:end_line] = line_number - 1
|
|
45
|
-
test_methods << current_test
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
current_test = {
|
|
49
|
-
name: test_name,
|
|
50
|
-
start_line: line_number,
|
|
51
|
-
end_line: lines.size # Default to end of file
|
|
52
|
-
}
|
|
53
|
-
elsif line =~ /^\s*end\s*(#.*)?$/ && current_test
|
|
54
|
-
# Found an end keyword - could be end of test method
|
|
55
|
-
# Simple heuristic: if we're at the same or less indentation level, close the test
|
|
56
|
-
current_indent = line[/^\s*/].length
|
|
23
|
+
def resolve_test_at_line(file_path, line_number)
|
|
24
|
+
resolve_identity(file_path, line_number).fetch(:name)
|
|
25
|
+
end
|
|
57
26
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
end
|
|
27
|
+
def resolve_identity(file_path, line_number, source: nil)
|
|
28
|
+
selector = "#{file_path}:#{line_number}"
|
|
29
|
+
source ||= File.read(file_path)
|
|
30
|
+
unless line_number.is_a?(Integer) && line_number.positive? && line_number <= source.lines.size
|
|
31
|
+
raise SelectionError, "Invalid test selector: #{selector}"
|
|
64
32
|
end
|
|
33
|
+
parsed = Prism.parse(source)
|
|
34
|
+
raise SelectionError, "Malformed Ruby in test selector: #{selector}" unless parsed.success?
|
|
65
35
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
36
|
+
declarations = []
|
|
37
|
+
scopes = Hash.new(0)
|
|
38
|
+
collect(parsed.value, [], false, true, declarations, scopes)
|
|
39
|
+
matches = declarations.select { |entry| (entry[:start_line]..entry[:end_line]).cover?(line_number) }
|
|
40
|
+
selected = matches.one? && matches.first
|
|
41
|
+
valid = selected && selected[:supported] && scopes[selected[:class_name]] == 1 &&
|
|
42
|
+
declarations.count { |entry| entry.values_at(:class_name, :name) == selected.values_at(:class_name, :name) } == 1
|
|
43
|
+
raise SelectionError, "Unmatched or ambiguous test selector: #{selector}" unless valid
|
|
71
44
|
|
|
72
|
-
|
|
45
|
+
selected.reject { |key, _| key == :supported }.merge(file: File.expand_path(file_path)).freeze
|
|
46
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::EISDIR => error
|
|
47
|
+
raise SelectionError, "Cannot read test selector #{selector}: #{error.class}"
|
|
73
48
|
end
|
|
74
49
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
50
|
+
def collect(node, namespace, in_class, supported, declarations, scopes)
|
|
51
|
+
return unless node
|
|
52
|
+
case node
|
|
53
|
+
when Prism::ProgramNode
|
|
54
|
+
collect(node.statements, namespace, in_class, supported, declarations, scopes)
|
|
55
|
+
when Prism::StatementsNode
|
|
56
|
+
node.body.each { |child| collect(child, namespace, in_class, supported, declarations, scopes) }
|
|
57
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
58
|
+
text = node.constant_path.location.slice
|
|
59
|
+
static = text.match?(/\A(?:::)?[A-Z]\w*(?:::[A-Z]\w*)*\z/)
|
|
60
|
+
parts = static ? text.delete_prefix("::").split("::") : []
|
|
61
|
+
path = text.start_with?("::") ? parts : namespace + parts
|
|
62
|
+
scopes[path.join("::")] += 1
|
|
63
|
+
collect(node.body, path, node.is_a?(Prism::ClassNode), supported && static, declarations, scopes)
|
|
64
|
+
when Prism::DefNode
|
|
65
|
+
if node.name.to_s.start_with?("test_")
|
|
66
|
+
add_declaration(node, node.name.to_s, namespace, supported && in_class && node.receiver.nil?,
|
|
67
|
+
node.def_keyword_loc.start_line, declarations)
|
|
68
|
+
end
|
|
69
|
+
# Nested declarations cannot acquire the surrounding static class's identity.
|
|
70
|
+
collect(node.body, namespace, in_class, false, declarations, scopes)
|
|
71
|
+
when Prism::CallNode
|
|
72
|
+
if node.name == :test && node.block.is_a?(Prism::BlockNode)
|
|
73
|
+
arguments = node.arguments&.arguments || []
|
|
74
|
+
literal = arguments.one? && arguments.first.is_a?(Prism::StringNode)
|
|
75
|
+
name = literal ? "test_#{arguments.first.unescaped.gsub(/\s+/, '_')}" : nil
|
|
76
|
+
static = supported && in_class && node.receiver.nil? && literal && node.block.opening_loc.slice == "do"
|
|
77
|
+
add_declaration(node, name, namespace, static, node.block.opening_loc.start_line, declarations)
|
|
78
|
+
end
|
|
79
|
+
node.compact_child_nodes.each { |child| collect(child, namespace, in_class, false, declarations, scopes) }
|
|
79
80
|
else
|
|
80
|
-
{
|
|
81
|
+
node.compact_child_nodes.each { |child| collect(child, namespace, in_class, false, declarations, scopes) }
|
|
81
82
|
end
|
|
82
83
|
end
|
|
84
|
+
|
|
85
|
+
def add_declaration(node, name, namespace, supported, method_line, declarations)
|
|
86
|
+
declarations << {name: name, class_name: namespace.join("::"), start_line: node.location.start_line,
|
|
87
|
+
end_line: node.location.end_line, method_line: method_line, supported: supported}
|
|
88
|
+
end
|
|
89
|
+
private_class_method :collect, :add_declaration
|
|
83
90
|
end
|
|
84
91
|
end
|
|
85
92
|
end
|
|
@@ -15,7 +15,7 @@ module Ace
|
|
|
15
15
|
# Pattern for inline errors - same structure as failures but with ERROR marker
|
|
16
16
|
inline_error: /^\s+(test_[\w_]+).*?ERROR.*?\([\d.]+s\)\n(.*?)(?=^\s+test_[\w_]+.*?(?:PASS|FAIL|ERROR|SKIP)|^Finished in|\z)/m,
|
|
17
17
|
location: /\[(.*?):(\d+)\]/,
|
|
18
|
-
duration: /Finished in ([\d.]+)s/,
|
|
18
|
+
duration: /Finished(?: tests)? in ([\d.]+)s/,
|
|
19
19
|
deprecation: /DEPRECATION WARNING: (.+)/,
|
|
20
20
|
# Pattern to capture individual test times from verbose output
|
|
21
21
|
# Matches Minitest::Reporters DefaultReporter format:
|
|
@@ -90,6 +90,7 @@ module Ace
|
|
|
90
90
|
# Execution mode options
|
|
91
91
|
option :parallel, type: :boolean, desc: "Run tests in parallel (experimental)"
|
|
92
92
|
option :per_file, type: :boolean, desc: "Execute each test file separately (slower, for debugging)"
|
|
93
|
+
# File-line selectors resolve exact declarations; mixed whole-file/line input refuses.
|
|
93
94
|
option :direct, type: :boolean, desc: "Force in-process execution (faster, less isolation)"
|
|
94
95
|
option :subprocess, type: :boolean, desc: "Force subprocess execution (slower, full isolation)"
|
|
95
96
|
option :run_in_sequence, type: :boolean, aliases: %w[--ris], desc: "Run test targets sequentially (default)"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module TestRunner
|
|
5
|
+
module Models
|
|
6
|
+
class TestSelectionPlan
|
|
7
|
+
attr_reader :files, :identities, :source_digests
|
|
8
|
+
|
|
9
|
+
def initialize(files:, identities:, source_digests:)
|
|
10
|
+
@files = freeze_values(files)
|
|
11
|
+
@identities = freeze_values(identities)
|
|
12
|
+
@source_digests = freeze_values(source_digests)
|
|
13
|
+
freeze
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def qualified?
|
|
17
|
+
!identities.empty?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def for_file(file)
|
|
21
|
+
path = File.expand_path(file.sub(/:\d+$/, ""))
|
|
22
|
+
self.class.new(files: [path],
|
|
23
|
+
identities: identities.select { |identity| identity.fetch(:file) == path },
|
|
24
|
+
source_digests: source_digests.select { |key, _| key == path })
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def freeze_values(value)
|
|
30
|
+
case value
|
|
31
|
+
when Hash
|
|
32
|
+
value.to_h { |key, entry| [freeze_values(key), freeze_values(entry)] }.freeze
|
|
33
|
+
when Array
|
|
34
|
+
value.map { |entry| freeze_values(entry) }.freeze
|
|
35
|
+
when String
|
|
36
|
+
value.dup.freeze
|
|
37
|
+
else
|
|
38
|
+
value.freeze
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -38,6 +38,8 @@ module Ace
|
|
|
38
38
|
#
|
|
39
39
|
# @return [Hash] Parsed options with :package_dir, :target, :files keys
|
|
40
40
|
def parse
|
|
41
|
+
require_relative "../atoms/line_number_resolver"
|
|
42
|
+
@argv.each { |argument| Atoms::LineNumberResolver.parse_file_with_line(argument) }
|
|
41
43
|
parse_first_argument
|
|
42
44
|
parse_remaining_arguments
|
|
43
45
|
|
|
@@ -125,6 +127,8 @@ module Ace
|
|
|
125
127
|
@test_files << arg
|
|
126
128
|
elsif package_relative_ruby_file?(arg)
|
|
127
129
|
@test_files << arg
|
|
130
|
+
elsif literal_ruby_file?(arg)
|
|
131
|
+
raise ArgumentError, "File not found: #{arg}"
|
|
128
132
|
elsif known_target?(arg)
|
|
129
133
|
@target = arg
|
|
130
134
|
elsif @target.nil? && !File.exist?(arg)
|
|
@@ -210,7 +214,7 @@ module Ace
|
|
|
210
214
|
# Check if an argument could be a package name (not a target, file, or file:line)
|
|
211
215
|
def potential_package?(arg)
|
|
212
216
|
!known_target?(arg) &&
|
|
213
|
-
!
|
|
217
|
+
!literal_ruby_file?(arg) &&
|
|
214
218
|
!file_with_line?(arg)
|
|
215
219
|
end
|
|
216
220
|
|
|
@@ -236,6 +240,10 @@ module Ace
|
|
|
236
240
|
@test_files << (@package_dir ? format_file_with_line(file_part, line_part) : arg)
|
|
237
241
|
end
|
|
238
242
|
|
|
243
|
+
def literal_ruby_file?(arg)
|
|
244
|
+
arg.end_with?(".rb") && !arg.match?(/[\*?\[\]{}]/)
|
|
245
|
+
end
|
|
246
|
+
|
|
239
247
|
def existing_ruby_file?(arg)
|
|
240
248
|
File.exist?(arg) && arg.end_with?(".rb")
|
|
241
249
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "stringio"
|
|
4
|
+
require "timeout"
|
|
4
5
|
require "ostruct"
|
|
5
6
|
require "minitest"
|
|
6
7
|
|
|
@@ -62,30 +63,11 @@ module Ace
|
|
|
62
63
|
require "minitest/autorun"
|
|
63
64
|
end
|
|
64
65
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
files.each do |file|
|
|
71
|
-
# Check if file has line number (file:line format)
|
|
72
|
-
if file =~ /^(.+):(\d+)$/
|
|
73
|
-
actual_file = $1
|
|
74
|
-
line_number = $2.to_i
|
|
75
|
-
|
|
76
|
-
# Resolve line number to test name
|
|
77
|
-
require_relative "../atoms/line_number_resolver"
|
|
78
|
-
test_name = Ace::TestRunner::Atoms::LineNumberResolver.resolve_test_at_line(actual_file, line_number)
|
|
79
|
-
test_names_to_run << test_name if test_name
|
|
80
|
-
|
|
81
|
-
files_to_load << actual_file
|
|
82
|
-
else
|
|
83
|
-
files_to_load << file
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
# Store test names in options to pass to run_minitest_with_args
|
|
88
|
-
options = options.merge(test_names_filter: test_names_to_run) if test_names_to_run.any?
|
|
66
|
+
require_relative "selection_resolver"
|
|
67
|
+
require_relative "selection_verifier"
|
|
68
|
+
selection = options[:selection_plan] || SelectionResolver.resolve(files)
|
|
69
|
+
SelectionVerifier.verify_sources!(selection)
|
|
70
|
+
files_to_load = selection.files
|
|
89
71
|
|
|
90
72
|
# Clear previously loaded test classes to avoid accumulation between groups
|
|
91
73
|
# This is crucial for in-process execution where tests from previous groups
|
|
@@ -128,14 +110,23 @@ module Ace
|
|
|
128
110
|
end
|
|
129
111
|
end
|
|
130
112
|
|
|
113
|
+
if selection.qualified?
|
|
114
|
+
options = options.merge(selection_pattern: SelectionVerifier.verify_loaded!(selection))
|
|
115
|
+
end
|
|
116
|
+
|
|
131
117
|
# Run Minitest with captured output
|
|
132
118
|
# Suppress Minitest's own output by using null reporter
|
|
133
|
-
|
|
134
|
-
|
|
119
|
+
execution = lambda do
|
|
120
|
+
if @timeout
|
|
121
|
+
Timeout.timeout(@timeout, ExecutionTimeout) { run_minitest_silent(options) }
|
|
122
|
+
else
|
|
135
123
|
run_minitest_silent(options)
|
|
136
124
|
end
|
|
125
|
+
end
|
|
126
|
+
exit_code = if selection.qualified?
|
|
127
|
+
SelectionVerifier.verify_execution!(selection, &execution)
|
|
137
128
|
else
|
|
138
|
-
|
|
129
|
+
execution.call
|
|
139
130
|
end
|
|
140
131
|
|
|
141
132
|
success = exit_code == true || exit_code == 0
|
|
@@ -242,9 +233,8 @@ module Ace
|
|
|
242
233
|
args << "--verbose" if options[:verbose]
|
|
243
234
|
|
|
244
235
|
# Add test name filter if line numbers were provided
|
|
245
|
-
if options[:
|
|
246
|
-
|
|
247
|
-
args << "--name" << "/#{pattern}/"
|
|
236
|
+
if options[:selection_pattern]
|
|
237
|
+
args << "--name" << "/#{options[:selection_pattern]}/"
|
|
248
238
|
end
|
|
249
239
|
|
|
250
240
|
# Run Minitest
|
|
@@ -261,9 +251,8 @@ module Ace
|
|
|
261
251
|
args << "--seed" << options[:seed].to_s if options[:seed]
|
|
262
252
|
|
|
263
253
|
# Add test name filter if line numbers were provided
|
|
264
|
-
if options[:
|
|
265
|
-
|
|
266
|
-
args << "--name" << "/#{pattern}/"
|
|
254
|
+
if options[:selection_pattern]
|
|
255
|
+
args << "--name" << "/#{options[:selection_pattern]}/"
|
|
267
256
|
end
|
|
268
257
|
|
|
269
258
|
# Minitest swallows Interrupt and returns a normal failure. Preserve
|
|
@@ -5,7 +5,8 @@ module Ace
|
|
|
5
5
|
module Molecules
|
|
6
6
|
class PatternResolver
|
|
7
7
|
INTERNAL_PATTERN_LABELS = {
|
|
8
|
-
"feat_tests" => "feat"
|
|
8
|
+
"feat_tests" => "feat",
|
|
9
|
+
"fast_tests" => "fast"
|
|
9
10
|
}.freeze
|
|
10
11
|
|
|
11
12
|
attr_reader :using_catch_all
|
|
@@ -42,7 +43,7 @@ module Ace
|
|
|
42
43
|
targets.flat_map { |target| resolve_target(target) }.uniq
|
|
43
44
|
end
|
|
44
45
|
|
|
45
|
-
def resolve_target_sequential(target_name, seen = [])
|
|
46
|
+
def resolve_target_sequential(target_name, seen = [], selected = {})
|
|
46
47
|
target_key = target_name.to_s
|
|
47
48
|
if seen.include?(target_key)
|
|
48
49
|
raise ArgumentError, "Cyclic test target definition detected: #{(seen + [target_key]).join(' -> ')}"
|
|
@@ -55,12 +56,14 @@ module Ace
|
|
|
55
56
|
member_key = member.to_s
|
|
56
57
|
|
|
57
58
|
if @targets.key?(member_key)
|
|
58
|
-
resolve_target_sequential(member_key, seen + [target_key])
|
|
59
|
+
resolve_target_sequential(member_key, seen + [target_key], selected)
|
|
59
60
|
elsif @patterns.key?(member_key)
|
|
60
|
-
files = expand_pattern(@patterns[member_key])
|
|
61
|
+
files = expand_pattern(@patterns[member_key]).reject { |file| selected.key?(file) }
|
|
62
|
+
files.each { |file| selected[file] = true }
|
|
61
63
|
files.empty? ? [] : [{name: display_name_for(member_key), files: files}]
|
|
62
64
|
else
|
|
63
|
-
files = expand_pattern(member)
|
|
65
|
+
files = expand_pattern(member).reject { |file| selected.key?(file) }
|
|
66
|
+
files.each { |file| selected[file] = true }
|
|
64
67
|
files.empty? ? [] : [{name: "other", files: files}]
|
|
65
68
|
end
|
|
66
69
|
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "../atoms/line_number_resolver"
|
|
5
|
+
require_relative "../models/test_selection_plan"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module TestRunner
|
|
9
|
+
module Molecules
|
|
10
|
+
module SelectionResolver
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def resolve(files)
|
|
14
|
+
parsed = Array(files).map { |selector| Atoms::LineNumberResolver.parse_file_with_line(selector) }
|
|
15
|
+
qualified = parsed.count { |entry| entry[:line] }
|
|
16
|
+
if qualified.positive? && qualified != parsed.size
|
|
17
|
+
raise Atoms::LineNumberResolver::SelectionError,
|
|
18
|
+
"Mixed whole-file and file:line selectors are unsupported: #{files.join(', ')}"
|
|
19
|
+
end
|
|
20
|
+
if qualified.zero?
|
|
21
|
+
return Models::TestSelectionPlan.new(files: Array(files), identities: [], source_digests: {})
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
sources = {}
|
|
25
|
+
identities = parsed.map do |entry|
|
|
26
|
+
path = File.expand_path(entry.fetch(:file))
|
|
27
|
+
source = sources[path] ||= File.read(path)
|
|
28
|
+
Atoms::LineNumberResolver.resolve_identity(path, entry.fetch(:line), source: source)
|
|
29
|
+
rescue SystemCallError => error
|
|
30
|
+
raise Atoms::LineNumberResolver::SelectionError,
|
|
31
|
+
"Cannot read test selector #{entry[:file]}:#{entry[:line]}: #{error.class}"
|
|
32
|
+
end
|
|
33
|
+
identities = identities.uniq { |entry| entry.values_at(:file, :class_name, :name) }
|
|
34
|
+
identities.group_by { |entry| entry.values_at(:class_name, :name) }.each_value do |matches|
|
|
35
|
+
next if matches.size == 1
|
|
36
|
+
selectors = matches.map { |entry| "#{entry[:file]}:#{entry[:start_line]}" }.join(", ")
|
|
37
|
+
raise Atoms::LineNumberResolver::SelectionError, "Ambiguous test identity across files: #{selectors}"
|
|
38
|
+
end
|
|
39
|
+
Models::TestSelectionPlan.new(files: sources.keys, identities: identities,
|
|
40
|
+
source_digests: sources.transform_values { |source| Digest::SHA256.hexdigest(source) })
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "digest"
|
|
3
|
+
require "minitest/reporters"
|
|
4
|
+
require_relative "../atoms/line_number_resolver"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module TestRunner
|
|
8
|
+
module Molecules
|
|
9
|
+
module SelectionVerifier
|
|
10
|
+
class ExecutionCollector < Minitest::Reporters::BaseReporter
|
|
11
|
+
attr_reader :identities
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
super
|
|
15
|
+
@identities = []
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def record(result)
|
|
20
|
+
@mutex.synchronize do
|
|
21
|
+
super
|
|
22
|
+
@identities << [result.class_name, result.name]
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def verify_execution!(plan)
|
|
30
|
+
previous = Minitest::Reporters.reporters
|
|
31
|
+
collector = ExecutionCollector.new
|
|
32
|
+
Minitest::Reporters.reporters = Array(previous) + [collector]
|
|
33
|
+
result = yield
|
|
34
|
+
expected = plan.identities.map { |identity| identity.values_at(:class_name, :name) }.sort
|
|
35
|
+
unless collector.identities.sort == expected
|
|
36
|
+
raise Atoms::LineNumberResolver::SelectionError,
|
|
37
|
+
"Executed test identities do not match selected identities"
|
|
38
|
+
end
|
|
39
|
+
result
|
|
40
|
+
ensure
|
|
41
|
+
Minitest::Reporters.reporters = previous
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def verify_sources!(plan)
|
|
45
|
+
plan.source_digests.each do |file, digest|
|
|
46
|
+
unless Digest::SHA256.file(file).hexdigest == digest
|
|
47
|
+
raise Atoms::LineNumberResolver::SelectionError, "Selected test source changed: #{file}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def verify_loaded!(plan, runnables: Minitest::Runnable.runnables)
|
|
53
|
+
verify_sources!(plan)
|
|
54
|
+
# Minitest's default randomized enumeration requires a seed even before run.
|
|
55
|
+
Minitest.seed ||= Random.new_seed
|
|
56
|
+
plan.identities.each do |identity|
|
|
57
|
+
matches = runnables.select { |klass| klass.name == identity.fetch(:class_name) }
|
|
58
|
+
klass = matches.first
|
|
59
|
+
valid = matches.size == 1 && klass < Minitest::Test && klass.public_instance_methods.include?(identity.fetch(:name).to_sym)
|
|
60
|
+
valid &&= klass.runnable_methods.count(identity.fetch(:name)) == 1
|
|
61
|
+
if valid
|
|
62
|
+
method = klass.instance_method(identity.fetch(:name))
|
|
63
|
+
source = method.source_location
|
|
64
|
+
valid = method.owner == klass && source &&
|
|
65
|
+
File.expand_path(source.first) == identity.fetch(:file) && source.last == identity.fetch(:method_line)
|
|
66
|
+
end
|
|
67
|
+
unless valid
|
|
68
|
+
raise Atoms::LineNumberResolver::SelectionError,
|
|
69
|
+
"Selected test identity does not match loaded runnable: #{identity[:class_name]}##{identity[:name]}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
names = plan.identities.map { |identity| Regexp.escape("#{identity[:class_name]}##{identity[:name]}") }
|
|
73
|
+
"\\A(?:#{names.join('|')})\\z"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "test_executor"
|
|
4
|
+
require_relative "selection_resolver"
|
|
4
5
|
require_relative "in_process_runner"
|
|
5
6
|
require_relative "../atoms/test_type_detector"
|
|
6
7
|
|
|
@@ -20,6 +21,7 @@ module Ace
|
|
|
20
21
|
return empty_result if files.empty?
|
|
21
22
|
|
|
22
23
|
# Determine execution mode
|
|
24
|
+
options = options.merge(selection_plan: options[:selection_plan] || SelectionResolver.resolve(files))
|
|
23
25
|
mode = determine_execution_mode(files, options)
|
|
24
26
|
|
|
25
27
|
# Execute with appropriate runner
|
|
@@ -31,6 +33,7 @@ module Ace
|
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
def execute_single_file(file, options = {})
|
|
36
|
+
options = options.merge(selection_plan: options[:selection_plan] || SelectionResolver.resolve([file]))
|
|
34
37
|
mode = determine_execution_mode([file], options)
|
|
35
38
|
|
|
36
39
|
if mode == :direct
|
|
@@ -46,6 +49,7 @@ module Ace
|
|
|
46
49
|
end
|
|
47
50
|
|
|
48
51
|
def execute_with_progress(files, options = {}, &block)
|
|
52
|
+
options = options.merge(selection_plan: options[:selection_plan] || SelectionResolver.resolve(files))
|
|
49
53
|
mode = determine_execution_mode(files, options)
|
|
50
54
|
|
|
51
55
|
# Add mode information to progress callback
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "open3"
|
|
4
4
|
require "timeout"
|
|
5
5
|
require "ostruct"
|
|
6
|
+
require_relative "selection_resolver"
|
|
6
7
|
|
|
7
8
|
module Ace
|
|
8
9
|
module TestRunner
|
|
@@ -39,15 +40,15 @@ module Ace
|
|
|
39
40
|
# environment (Process.spawn merges onto the parent by default).
|
|
40
41
|
# MT_NO_AUTORUN is part of the fixture environment.
|
|
41
42
|
env = @launch_env
|
|
42
|
-
command = command.sub(/^MT_NO_AUTORUN=1\s+/, "")
|
|
43
|
+
command = command.sub(/^MT_NO_AUTORUN=1\s+/, "") if command.is_a?(String)
|
|
43
44
|
|
|
44
45
|
begin
|
|
45
46
|
if @timeout
|
|
46
47
|
Timeout.timeout(@timeout) do
|
|
47
|
-
stdout, stderr, status = Open3.capture3(env, command, unsetenv_others: true)
|
|
48
|
+
stdout, stderr, status = Open3.capture3(env, *Array(command), unsetenv_others: true)
|
|
48
49
|
end
|
|
49
50
|
else
|
|
50
|
-
stdout, stderr, status = Open3.capture3(env, command, unsetenv_others: true)
|
|
51
|
+
stdout, stderr, status = Open3.capture3(env, *Array(command), unsetenv_others: true)
|
|
51
52
|
end
|
|
52
53
|
rescue Timeout::Error
|
|
53
54
|
stderr = "Test execution timed out after #{@timeout} seconds"
|
|
@@ -69,6 +70,7 @@ module Ace
|
|
|
69
70
|
end
|
|
70
71
|
|
|
71
72
|
def execute_with_progress(files, options = {}, &block)
|
|
73
|
+
options = options.merge(selection_plan: options[:selection_plan] || SelectionResolver.resolve(files))
|
|
72
74
|
# Fail-fast requires per-file execution to stop on first failure
|
|
73
75
|
# For performance, execute all files together unless explicitly disabled or fail-fast enabled
|
|
74
76
|
if options[:per_file] == true || options[:fail_fast]
|
|
@@ -93,12 +95,16 @@ module Ace
|
|
|
93
95
|
end
|
|
94
96
|
|
|
95
97
|
def execute_per_file_with_progress(files, options = {}, &block)
|
|
98
|
+
options = options.merge(selection_plan: options[:selection_plan] || SelectionResolver.resolve(files))
|
|
96
99
|
results = []
|
|
100
|
+
plan = options.fetch(:selection_plan)
|
|
101
|
+
files = plan.files if plan.qualified?
|
|
97
102
|
|
|
98
103
|
files.each do |file|
|
|
99
104
|
yield({type: :start, file: file}) if block_given?
|
|
100
105
|
|
|
101
|
-
|
|
106
|
+
file_options = plan.qualified? ? options.merge(selection_plan: plan.for_file(file)) : options
|
|
107
|
+
result = execute_single_file(file, file_options)
|
|
102
108
|
results << result
|
|
103
109
|
|
|
104
110
|
if block_given?
|
|
@@ -265,6 +265,9 @@ module Ace
|
|
|
265
265
|
end
|
|
266
266
|
end
|
|
267
267
|
|
|
268
|
+
# Only dispatched targets belong in the execution report after an early stop.
|
|
269
|
+
all_files = execution_result.fetch(:files)
|
|
270
|
+
|
|
268
271
|
# Use the parsed result from sequential executor
|
|
269
272
|
@parsed_result = execution_result[:parsed_result]
|
|
270
273
|
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-test-runner
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.28.
|
|
4
|
+
version: 0.28.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-08 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-support-fs
|
|
@@ -121,6 +121,20 @@ dependencies:
|
|
|
121
121
|
- - "~>"
|
|
122
122
|
- !ruby/object:Gem::Version
|
|
123
123
|
version: '1.8'
|
|
124
|
+
- !ruby/object:Gem::Dependency
|
|
125
|
+
name: prism
|
|
126
|
+
requirement: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - "~>"
|
|
129
|
+
- !ruby/object:Gem::Version
|
|
130
|
+
version: '1.9'
|
|
131
|
+
type: :runtime
|
|
132
|
+
prerelease: false
|
|
133
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
134
|
+
requirements:
|
|
135
|
+
- - "~>"
|
|
136
|
+
- !ruby/object:Gem::Version
|
|
137
|
+
version: '1.9'
|
|
124
138
|
- !ruby/object:Gem::Dependency
|
|
125
139
|
name: ostruct
|
|
126
140
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -207,6 +221,7 @@ files:
|
|
|
207
221
|
- lib/ace/test_runner/models/test_failure.rb
|
|
208
222
|
- lib/ace/test_runner/models/test_report.rb
|
|
209
223
|
- lib/ace/test_runner/models/test_result.rb
|
|
224
|
+
- lib/ace/test_runner/models/test_selection_plan.rb
|
|
210
225
|
- lib/ace/test_runner/models/test_target.rb
|
|
211
226
|
- lib/ace/test_runner/molecules/cli_argument_parser.rb
|
|
212
227
|
- lib/ace/test_runner/molecules/config_loader.rb
|
|
@@ -220,6 +235,8 @@ files:
|
|
|
220
235
|
- lib/ace/test_runner/molecules/pattern_resolver.rb
|
|
221
236
|
- lib/ace/test_runner/molecules/rake_integration.rb
|
|
222
237
|
- lib/ace/test_runner/molecules/report_storage.rb
|
|
238
|
+
- lib/ace/test_runner/molecules/selection_resolver.rb
|
|
239
|
+
- lib/ace/test_runner/molecules/selection_verifier.rb
|
|
223
240
|
- lib/ace/test_runner/molecules/smart_test_executor.rb
|
|
224
241
|
- lib/ace/test_runner/molecules/test_executor.rb
|
|
225
242
|
- lib/ace/test_runner/organisms/agent_reporter.rb
|