ace-test-runner 0.18.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/.ace-defaults/test/runner.yml +35 -0
- data/.ace-defaults/test/suite.yml +31 -0
- data/.ace-defaults/test-runner/config.yml +61 -0
- data/CHANGELOG.md +626 -0
- data/LICENSE +21 -0
- data/README.md +42 -0
- data/Rakefile +14 -0
- data/exe/ace-test +26 -0
- data/exe/ace-test-suite +149 -0
- data/lib/ace/test_runner/atoms/command_builder.rb +165 -0
- data/lib/ace/test_runner/atoms/lazy_loader.rb +62 -0
- data/lib/ace/test_runner/atoms/line_number_resolver.rb +86 -0
- data/lib/ace/test_runner/atoms/report_directory_resolver.rb +48 -0
- data/lib/ace/test_runner/atoms/report_path_resolver.rb +67 -0
- data/lib/ace/test_runner/atoms/result_parser.rb +254 -0
- data/lib/ace/test_runner/atoms/test_detector.rb +114 -0
- data/lib/ace/test_runner/atoms/test_folder_detector.rb +53 -0
- data/lib/ace/test_runner/atoms/test_type_detector.rb +83 -0
- data/lib/ace/test_runner/atoms/timestamp_generator.rb +103 -0
- data/lib/ace/test_runner/cli/commands/test.rb +326 -0
- data/lib/ace/test_runner/cli.rb +16 -0
- data/lib/ace/test_runner/formatters/base_formatter.rb +102 -0
- data/lib/ace/test_runner/formatters/json_formatter.rb +90 -0
- data/lib/ace/test_runner/formatters/markdown_formatter.rb +91 -0
- data/lib/ace/test_runner/formatters/progress_file_formatter.rb +164 -0
- data/lib/ace/test_runner/formatters/progress_formatter.rb +328 -0
- data/lib/ace/test_runner/models/test_configuration.rb +165 -0
- data/lib/ace/test_runner/models/test_failure.rb +95 -0
- data/lib/ace/test_runner/models/test_group.rb +105 -0
- data/lib/ace/test_runner/models/test_report.rb +145 -0
- data/lib/ace/test_runner/models/test_result.rb +86 -0
- data/lib/ace/test_runner/molecules/cli_argument_parser.rb +263 -0
- data/lib/ace/test_runner/molecules/config_loader.rb +162 -0
- data/lib/ace/test_runner/molecules/deprecation_fixer.rb +204 -0
- data/lib/ace/test_runner/molecules/failed_package_reporter.rb +100 -0
- data/lib/ace/test_runner/molecules/failure_analyzer.rb +249 -0
- data/lib/ace/test_runner/molecules/in_process_runner.rb +249 -0
- data/lib/ace/test_runner/molecules/package_resolver.rb +106 -0
- data/lib/ace/test_runner/molecules/pattern_resolver.rb +146 -0
- data/lib/ace/test_runner/molecules/rake_integration.rb +218 -0
- data/lib/ace/test_runner/molecules/report_storage.rb +303 -0
- data/lib/ace/test_runner/molecules/smart_test_executor.rb +107 -0
- data/lib/ace/test_runner/molecules/test_executor.rb +162 -0
- data/lib/ace/test_runner/organisms/agent_reporter.rb +384 -0
- data/lib/ace/test_runner/organisms/report_generator.rb +151 -0
- data/lib/ace/test_runner/organisms/sequential_group_executor.rb +185 -0
- data/lib/ace/test_runner/organisms/test_orchestrator.rb +648 -0
- data/lib/ace/test_runner/rake_task.rb +90 -0
- data/lib/ace/test_runner/suite/display_helpers.rb +117 -0
- data/lib/ace/test_runner/suite/display_manager.rb +204 -0
- data/lib/ace/test_runner/suite/duration_estimator.rb +50 -0
- data/lib/ace/test_runner/suite/orchestrator.rb +120 -0
- data/lib/ace/test_runner/suite/process_monitor.rb +268 -0
- data/lib/ace/test_runner/suite/result_aggregator.rb +176 -0
- data/lib/ace/test_runner/suite/simple_display_manager.rb +122 -0
- data/lib/ace/test_runner/suite.rb +22 -0
- data/lib/ace/test_runner/version.rb +7 -0
- data/lib/ace/test_runner.rb +69 -0
- metadata +246 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module TestRunner
|
|
5
|
+
module Models
|
|
6
|
+
# Represents a single test failure or error
|
|
7
|
+
class TestFailure
|
|
8
|
+
attr_accessor :type, :test_name, :test_class, :message, :file_path,
|
|
9
|
+
:line_number, :backtrace, :fix_suggestion, :code_context,
|
|
10
|
+
:stderr_warnings
|
|
11
|
+
|
|
12
|
+
def initialize(attributes = {})
|
|
13
|
+
@type = attributes[:type] || :failure # :failure or :error
|
|
14
|
+
@test_name = attributes[:test_name]
|
|
15
|
+
@test_class = attributes[:test_class]
|
|
16
|
+
@message = attributes[:message]
|
|
17
|
+
@file_path = attributes[:file_path]
|
|
18
|
+
@line_number = attributes[:line_number]
|
|
19
|
+
@backtrace = attributes[:backtrace] || []
|
|
20
|
+
@fix_suggestion = attributes[:fix_suggestion]
|
|
21
|
+
@code_context = attributes[:code_context]
|
|
22
|
+
@stderr_warnings = attributes[:stderr_warnings]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def location
|
|
26
|
+
return nil unless file_path
|
|
27
|
+
|
|
28
|
+
if line_number
|
|
29
|
+
"#{file_path}:#{line_number}"
|
|
30
|
+
else
|
|
31
|
+
file_path
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def short_location
|
|
36
|
+
return nil unless file_path
|
|
37
|
+
|
|
38
|
+
base_name = File.basename(file_path)
|
|
39
|
+
line_number ? "#{base_name}:#{line_number}" : base_name
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def full_test_name
|
|
43
|
+
test_class ? "#{test_class}##{test_name}" : test_name.to_s
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def error?
|
|
47
|
+
type == :error
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def failure?
|
|
51
|
+
type == :failure
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def summary_line
|
|
55
|
+
"#{type_icon} #{full_test_name} - #{short_location}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def detailed_description
|
|
59
|
+
lines = []
|
|
60
|
+
lines << "#{type_icon} #{type.to_s.capitalize}: #{full_test_name}"
|
|
61
|
+
lines << " Location: #{location}" if location
|
|
62
|
+
lines << " Message: #{message}" if message
|
|
63
|
+
lines << " Fix: #{fix_suggestion}" if fix_suggestion
|
|
64
|
+
lines.join("\n")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def to_h
|
|
68
|
+
{
|
|
69
|
+
type: type,
|
|
70
|
+
test_name: test_name,
|
|
71
|
+
test_class: test_class,
|
|
72
|
+
message: message,
|
|
73
|
+
location: location,
|
|
74
|
+
file_path: file_path,
|
|
75
|
+
line_number: line_number,
|
|
76
|
+
backtrace: backtrace,
|
|
77
|
+
fix_suggestion: fix_suggestion,
|
|
78
|
+
code_context: code_context,
|
|
79
|
+
stderr_warnings: stderr_warnings
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def to_json(*args)
|
|
84
|
+
to_h.to_json(*args)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def type_icon
|
|
90
|
+
error? ? "💥" : "❌"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module TestRunner
|
|
5
|
+
module Models
|
|
6
|
+
# Represents a group of tests to be executed together
|
|
7
|
+
class TestGroup
|
|
8
|
+
attr_reader :name, :patterns, :files, :options
|
|
9
|
+
|
|
10
|
+
def initialize(name:, patterns: [], files: [], options: {})
|
|
11
|
+
@name = name
|
|
12
|
+
@patterns = Array(patterns)
|
|
13
|
+
@files = Array(files)
|
|
14
|
+
@options = options
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Find all test files matching this group's patterns
|
|
18
|
+
def find_files
|
|
19
|
+
return @files unless @files.empty?
|
|
20
|
+
|
|
21
|
+
detector = Atoms::TestDetector.new(patterns: @patterns)
|
|
22
|
+
detector.find_test_files
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Execute this group's tests
|
|
26
|
+
def execute(executor, formatter_options = {})
|
|
27
|
+
test_files = find_files
|
|
28
|
+
return empty_result if test_files.empty?
|
|
29
|
+
|
|
30
|
+
options = @options.merge(formatter_options)
|
|
31
|
+
executor.execute_tests(test_files, options)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Check if a file belongs to this group
|
|
35
|
+
def includes_file?(file_path)
|
|
36
|
+
return true if @files.include?(file_path)
|
|
37
|
+
|
|
38
|
+
@patterns.any? do |pattern|
|
|
39
|
+
File.fnmatch(pattern, file_path, File::FNM_PATHNAME)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def to_h
|
|
44
|
+
{
|
|
45
|
+
name: @name,
|
|
46
|
+
patterns: @patterns,
|
|
47
|
+
files: @files,
|
|
48
|
+
options: @options
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Load groups from configuration
|
|
53
|
+
def self.from_config(config)
|
|
54
|
+
groups = config.fetch("groups", default_groups)
|
|
55
|
+
|
|
56
|
+
groups.map do |name, definition|
|
|
57
|
+
new(
|
|
58
|
+
name: name,
|
|
59
|
+
patterns: definition["patterns"] || [],
|
|
60
|
+
files: definition["files"] || [],
|
|
61
|
+
options: definition.fetch("options", {}).transform_keys(&:to_sym)
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Default test groups for common Ruby project structures
|
|
67
|
+
def self.default_groups
|
|
68
|
+
{
|
|
69
|
+
"unit" => {
|
|
70
|
+
"patterns" => ["test/unit/**/*_test.rb", "test/*_test.rb"],
|
|
71
|
+
"options" => {}
|
|
72
|
+
},
|
|
73
|
+
"integration" => {
|
|
74
|
+
"patterns" => ["test/integration/**/*_test.rb"],
|
|
75
|
+
"options" => {}
|
|
76
|
+
},
|
|
77
|
+
"system" => {
|
|
78
|
+
"patterns" => ["test/system/**/*_test.rb"],
|
|
79
|
+
"options" => {"timeout" => 60}
|
|
80
|
+
},
|
|
81
|
+
"all" => {
|
|
82
|
+
"patterns" => ["test/**/*_test.rb"],
|
|
83
|
+
"options" => {}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def empty_result
|
|
91
|
+
{
|
|
92
|
+
stdout: "",
|
|
93
|
+
stderr: "No test files found for group '#{@name}'",
|
|
94
|
+
status: OpenStruct.new(success?: true, exitstatus: 0),
|
|
95
|
+
command: "",
|
|
96
|
+
start_time: Time.now,
|
|
97
|
+
end_time: Time.now,
|
|
98
|
+
duration: 0.0,
|
|
99
|
+
success: true
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module TestRunner
|
|
7
|
+
module Models
|
|
8
|
+
# Represents a complete test report
|
|
9
|
+
class TestReport
|
|
10
|
+
attr_accessor :result, :configuration, :timestamp, :report_path,
|
|
11
|
+
:files_tested, :environment, :metadata
|
|
12
|
+
|
|
13
|
+
def initialize(attributes = {})
|
|
14
|
+
@result = attributes[:result] || TestResult.new
|
|
15
|
+
@configuration = attributes[:configuration] || TestConfiguration.new
|
|
16
|
+
@timestamp = attributes[:timestamp] || Time.now
|
|
17
|
+
@report_path = attributes[:report_path]
|
|
18
|
+
@files_tested = attributes[:files_tested] || []
|
|
19
|
+
@environment = attributes[:environment] || capture_environment
|
|
20
|
+
@metadata = attributes[:metadata] || {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def success?
|
|
24
|
+
result.success?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def summary
|
|
28
|
+
{
|
|
29
|
+
status: success? ? "success" : "failure",
|
|
30
|
+
passed: result.passed,
|
|
31
|
+
failed: result.failed,
|
|
32
|
+
errors: result.errors,
|
|
33
|
+
skipped: result.skipped,
|
|
34
|
+
total: result.total_tests,
|
|
35
|
+
pass_rate: result.pass_rate,
|
|
36
|
+
duration: result.duration,
|
|
37
|
+
timestamp: timestamp.iso8601
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def failure_summary
|
|
42
|
+
return [] unless result.has_failures?
|
|
43
|
+
|
|
44
|
+
result.failures_detail.map do |failure|
|
|
45
|
+
{
|
|
46
|
+
type: failure.type,
|
|
47
|
+
test: failure.full_test_name,
|
|
48
|
+
location: failure.location,
|
|
49
|
+
message: failure.message
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def to_h
|
|
55
|
+
{
|
|
56
|
+
summary: summary,
|
|
57
|
+
result: result.to_h,
|
|
58
|
+
configuration: configuration.to_h,
|
|
59
|
+
timestamp: timestamp.iso8601,
|
|
60
|
+
report_path: report_path,
|
|
61
|
+
files_tested: files_tested,
|
|
62
|
+
environment: environment,
|
|
63
|
+
failures: failure_summary,
|
|
64
|
+
deprecations: result.deprecations,
|
|
65
|
+
metadata: metadata
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def to_json(*args)
|
|
70
|
+
to_h.to_json(*args)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def to_markdown
|
|
74
|
+
lines = []
|
|
75
|
+
lines << "# Test Report"
|
|
76
|
+
lines << ""
|
|
77
|
+
lines << "**Generated:** #{timestamp.strftime("%Y-%m-%d %H:%M:%S")}"
|
|
78
|
+
lines << "**Status:** #{success? ? "✅ Success" : "❌ Failed"}"
|
|
79
|
+
lines << ""
|
|
80
|
+
|
|
81
|
+
lines << "## Summary"
|
|
82
|
+
lines << ""
|
|
83
|
+
lines << "| Metric | Value |"
|
|
84
|
+
lines << "|--------|-------|"
|
|
85
|
+
lines << "| Total Tests | #{result.total_tests} |"
|
|
86
|
+
lines << "| Passed | #{result.passed} |"
|
|
87
|
+
lines << "| Failed | #{result.failed} |"
|
|
88
|
+
lines << "| Errors | #{result.errors} |"
|
|
89
|
+
lines << "| Skipped | #{result.skipped} |"
|
|
90
|
+
lines << "| Pass Rate | #{result.pass_rate}% |"
|
|
91
|
+
lines << "| Duration | #{result.duration}s |"
|
|
92
|
+
lines << ""
|
|
93
|
+
|
|
94
|
+
if result.has_failures?
|
|
95
|
+
lines << "## Failures"
|
|
96
|
+
lines << ""
|
|
97
|
+
result.failures_detail.each_with_index do |failure, idx|
|
|
98
|
+
lines << "### #{idx + 1}. #{failure.full_test_name}"
|
|
99
|
+
lines << ""
|
|
100
|
+
lines << "- **Type:** #{failure.type}"
|
|
101
|
+
lines << "- **Location:** `#{failure.location}`"
|
|
102
|
+
lines << "- **Message:** #{failure.message}"
|
|
103
|
+
lines << "- **Fix:** #{failure.fix_suggestion}" if failure.fix_suggestion
|
|
104
|
+
lines << ""
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
if result.has_deprecations?
|
|
109
|
+
lines << "## Deprecations"
|
|
110
|
+
lines << ""
|
|
111
|
+
result.deprecations.each do |deprecation|
|
|
112
|
+
lines << "- #{deprecation}"
|
|
113
|
+
end
|
|
114
|
+
lines << ""
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
if files_tested.any?
|
|
118
|
+
lines << "## Files Tested"
|
|
119
|
+
lines << ""
|
|
120
|
+
files_tested.each do |file|
|
|
121
|
+
lines << "- #{file}"
|
|
122
|
+
end
|
|
123
|
+
lines << ""
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
lines.join("\n")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def capture_environment
|
|
132
|
+
{
|
|
133
|
+
ruby_version: RUBY_VERSION,
|
|
134
|
+
ruby_platform: RUBY_PLATFORM,
|
|
135
|
+
minitest_version: defined?(Minitest::VERSION) ? Minitest::VERSION : "unknown",
|
|
136
|
+
ace_test_runner_version: VERSION,
|
|
137
|
+
working_directory: Dir.pwd,
|
|
138
|
+
user: ENV["USER"],
|
|
139
|
+
hostname: ENV["HOSTNAME"] || Socket.gethostname
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module TestRunner
|
|
5
|
+
module Models
|
|
6
|
+
# Represents the result of a test run
|
|
7
|
+
class TestResult
|
|
8
|
+
attr_accessor :passed, :failed, :skipped, :errors, :assertions,
|
|
9
|
+
:duration, :start_time, :end_time, :failures_detail,
|
|
10
|
+
:deprecations, :raw_output, :stderr
|
|
11
|
+
|
|
12
|
+
def initialize(attributes = {})
|
|
13
|
+
@passed = attributes[:passed] || 0
|
|
14
|
+
@failed = attributes[:failed] || 0
|
|
15
|
+
@skipped = attributes[:skipped] || 0
|
|
16
|
+
@errors = attributes[:errors] || 0
|
|
17
|
+
@assertions = attributes[:assertions] || 0
|
|
18
|
+
@duration = attributes[:duration] || 0.0
|
|
19
|
+
@start_time = attributes[:start_time]
|
|
20
|
+
@end_time = attributes[:end_time]
|
|
21
|
+
@failures_detail = attributes[:failures_detail] || []
|
|
22
|
+
@deprecations = attributes[:deprecations] || []
|
|
23
|
+
@raw_output = attributes[:raw_output] || ""
|
|
24
|
+
@stderr = attributes[:stderr] || ""
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def total_tests
|
|
28
|
+
passed + failed + skipped + errors
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def success?
|
|
32
|
+
failed == 0 && errors == 0
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def has_failures?
|
|
36
|
+
failed > 0 || errors > 0
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def has_skips?
|
|
40
|
+
skipped > 0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def has_deprecations?
|
|
44
|
+
deprecations.any?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def pass_rate
|
|
48
|
+
return 0.0 if total_tests == 0
|
|
49
|
+
(passed.to_f / total_tests * 100).round(2)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def summary_line
|
|
53
|
+
parts = []
|
|
54
|
+
parts << "✅ #{passed} passed" if passed > 0
|
|
55
|
+
parts << "❌ #{failed} failed" if failed > 0
|
|
56
|
+
parts << "💥 #{errors} errors" if errors > 0
|
|
57
|
+
parts << "⚠️ #{skipped} skipped" if skipped > 0
|
|
58
|
+
|
|
59
|
+
parts.empty? ? "No tests executed" : parts.join(", ")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def to_h
|
|
63
|
+
{
|
|
64
|
+
passed: passed,
|
|
65
|
+
failed: failed,
|
|
66
|
+
skipped: skipped,
|
|
67
|
+
errors: errors,
|
|
68
|
+
assertions: assertions,
|
|
69
|
+
total_tests: total_tests,
|
|
70
|
+
duration: duration,
|
|
71
|
+
pass_rate: pass_rate,
|
|
72
|
+
success: success?,
|
|
73
|
+
start_time: start_time&.iso8601,
|
|
74
|
+
end_time: end_time&.iso8601,
|
|
75
|
+
failures: failures_detail.map(&:to_h),
|
|
76
|
+
deprecations: deprecations
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def to_json(*args)
|
|
81
|
+
to_h.to_json(*args)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "package_resolver"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module TestRunner
|
|
7
|
+
module Molecules
|
|
8
|
+
# Parses CLI arguments to identify package, target, and test files.
|
|
9
|
+
# Handles the complexity of distinguishing between:
|
|
10
|
+
# - Direct file paths (./path/file.rb, ../path/file.rb, /abs/path/file.rb)
|
|
11
|
+
# - Package-prefixed file paths (ace-bundle/test/file.rb)
|
|
12
|
+
# - Package names (ace-bundle)
|
|
13
|
+
# - Test targets (atoms, molecules, unit, etc.)
|
|
14
|
+
class CliArgumentParser
|
|
15
|
+
KNOWN_TARGETS = %w[atoms molecules organisms models unit integration system all quick].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :package_dir, :target, :test_files
|
|
18
|
+
|
|
19
|
+
# @param argv [Array<String>] Command line arguments (will be modified)
|
|
20
|
+
# @param package_resolver [PackageResolver] Optional resolver for testing
|
|
21
|
+
def initialize(argv, package_resolver: nil)
|
|
22
|
+
@argv = argv
|
|
23
|
+
@package_resolver = package_resolver || PackageResolver.new
|
|
24
|
+
@package_dir = nil
|
|
25
|
+
@target = nil
|
|
26
|
+
@test_files = []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Parse arguments and populate package_dir, target, test_files.
|
|
30
|
+
#
|
|
31
|
+
# Parsing precedence (order matters for correct classification):
|
|
32
|
+
# 1. Existing files (./path/file.rb, ../path/file.rb) - direct file paths
|
|
33
|
+
# 2. Package-prefixed file paths (ace-bundle/test/file.rb) - sets package + adds file
|
|
34
|
+
# 3. Package names (ace-bundle) - sets package directory
|
|
35
|
+
# 4. Known targets (atoms, molecules, etc.) - sets test target
|
|
36
|
+
# 5. Relative file paths within package (test/file.rb when package is set)
|
|
37
|
+
# 6. Unrecognized args - treated as custom targets for PatternResolver
|
|
38
|
+
#
|
|
39
|
+
# @return [Hash] Parsed options with :package_dir, :target, :files keys
|
|
40
|
+
def parse
|
|
41
|
+
parse_first_argument
|
|
42
|
+
parse_remaining_arguments
|
|
43
|
+
|
|
44
|
+
result = {}
|
|
45
|
+
result[:package_dir] = @package_dir if @package_dir
|
|
46
|
+
result[:target] = @target if @target
|
|
47
|
+
result[:files] = @test_files unless @test_files.empty?
|
|
48
|
+
result
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Check if an argument is a known test target
|
|
52
|
+
# @param arg [String] The argument to check
|
|
53
|
+
# @return [Boolean]
|
|
54
|
+
def known_target?(arg)
|
|
55
|
+
KNOWN_TARGETS.include?(arg)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Extract file path and optional line number from a path string.
|
|
61
|
+
# Handles both "file.rb" and "file.rb:42" formats.
|
|
62
|
+
# @param path [String] File path, optionally with :line suffix
|
|
63
|
+
# @return [Array(String, String|nil)] [file_path, line_number]
|
|
64
|
+
def extract_file_and_line(path)
|
|
65
|
+
if path =~ /^(.+\.rb):(\d+)$/
|
|
66
|
+
[$1, $2]
|
|
67
|
+
else
|
|
68
|
+
[path, nil]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Split a package-prefixed path into package name and file path.
|
|
73
|
+
# Example: "ace-bundle/test/file.rb" -> ["ace-bundle", "test/file.rb"]
|
|
74
|
+
# @param arg [String] Package-prefixed path
|
|
75
|
+
# @return [Array(String, String)] [package_name, file_path]
|
|
76
|
+
def split_package_prefix(arg)
|
|
77
|
+
arg.split("/", 2)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Format a file path with optional line number.
|
|
81
|
+
# @param file_path [String] The file path
|
|
82
|
+
# @param line_number [String, nil] Optional line number
|
|
83
|
+
# @return [String] Formatted path (e.g., "file.rb" or "file.rb:42")
|
|
84
|
+
def format_file_with_line(file_path, line_number)
|
|
85
|
+
line_number ? "#{file_path}:#{line_number}" : file_path
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_first_argument
|
|
89
|
+
first_arg_index = @argv.find_index { |arg| !arg.start_with?("-") }
|
|
90
|
+
return unless first_arg_index
|
|
91
|
+
|
|
92
|
+
first_arg = @argv[first_arg_index]
|
|
93
|
+
|
|
94
|
+
# First, check if the argument is an existing file (handles ./path/file.rb, ../path/file.rb)
|
|
95
|
+
# This must be checked BEFORE package detection to avoid misclassification
|
|
96
|
+
if existing_file?(first_arg)
|
|
97
|
+
@test_files << first_arg
|
|
98
|
+
@argv.delete_at(first_arg_index)
|
|
99
|
+
return
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Check for package-prefixed file path (e.g., ace-bundle/test/foo_test.rb)
|
|
103
|
+
if package_prefixed_file_path?(first_arg)
|
|
104
|
+
handle_package_prefixed_path(first_arg, first_arg_index)
|
|
105
|
+
return
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Check if first arg is a package (not a known target, not a ruby file, not file:line)
|
|
109
|
+
return if @package_dir || !@test_files.empty?
|
|
110
|
+
|
|
111
|
+
handle_potential_package(first_arg, first_arg_index)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def parse_remaining_arguments
|
|
115
|
+
@argv.each do |arg|
|
|
116
|
+
next if arg.start_with?("-")
|
|
117
|
+
|
|
118
|
+
# Check package-prefixed paths first (before file_with_line?) to handle
|
|
119
|
+
# cases like "ace-bundle/test/file.rb:42" correctly when package is resolved
|
|
120
|
+
if @package_dir && handle_remaining_package_prefixed_path(arg)
|
|
121
|
+
next
|
|
122
|
+
elsif file_with_line?(arg)
|
|
123
|
+
handle_file_with_line(arg)
|
|
124
|
+
elsif existing_ruby_file?(arg)
|
|
125
|
+
@test_files << arg
|
|
126
|
+
elsif package_relative_ruby_file?(arg)
|
|
127
|
+
@test_files << arg
|
|
128
|
+
elsif known_target?(arg)
|
|
129
|
+
@target = arg
|
|
130
|
+
elsif @target.nil? && !File.exist?(arg)
|
|
131
|
+
# Unrecognized target - will be handled by PatternResolver
|
|
132
|
+
@target = arg
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def existing_file?(arg)
|
|
138
|
+
file_arg = arg.sub(/:\d+$/, "") # Strip line number if present
|
|
139
|
+
File.file?(file_arg) && file_arg.end_with?(".rb")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def package_prefixed_file_path?(arg)
|
|
143
|
+
arg.include?("/") && (arg.end_with?(".rb") || arg =~ /\.rb:\d+$/)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Handle package-prefixed file paths in remaining args when package already resolved.
|
|
147
|
+
# Example: "ace-bundle ace-bundle/test/foo.rb" - second arg should be recognized
|
|
148
|
+
# as a file within the already-resolved package.
|
|
149
|
+
# @return [Boolean] true if arg was handled as a package-prefixed file
|
|
150
|
+
def handle_remaining_package_prefixed_path(arg)
|
|
151
|
+
return false unless package_prefixed_file_path?(arg)
|
|
152
|
+
|
|
153
|
+
potential_package, file_path = split_package_prefix(arg)
|
|
154
|
+
|
|
155
|
+
# Check if the prefix matches our resolved package
|
|
156
|
+
resolved_package = @package_resolver.resolve(potential_package)
|
|
157
|
+
return false unless resolved_package == @package_dir
|
|
158
|
+
|
|
159
|
+
file_path_only, line_number = extract_file_and_line(file_path)
|
|
160
|
+
|
|
161
|
+
full_file_path = File.join(@package_dir, file_path_only)
|
|
162
|
+
return false unless File.exist?(full_file_path)
|
|
163
|
+
|
|
164
|
+
@test_files << format_file_with_line(file_path_only, line_number)
|
|
165
|
+
true
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def handle_package_prefixed_path(arg, index)
|
|
169
|
+
# Split into package name and file path using first "/" as delimiter.
|
|
170
|
+
# This assumes package names don't contain "/" (true for all ace-* packages).
|
|
171
|
+
# Note: If a package were ever named with "/" (e.g., "ace/context"), this
|
|
172
|
+
# would misparse it. Current ace-* naming convention prevents this issue.
|
|
173
|
+
potential_package, file_path = split_package_prefix(arg)
|
|
174
|
+
file_path_only, line_number = extract_file_and_line(file_path)
|
|
175
|
+
|
|
176
|
+
# Try to resolve the package
|
|
177
|
+
resolved_package = @package_resolver.resolve(potential_package)
|
|
178
|
+
return unless resolved_package
|
|
179
|
+
|
|
180
|
+
full_file_path = File.join(resolved_package, file_path_only)
|
|
181
|
+
return unless File.exist?(full_file_path)
|
|
182
|
+
|
|
183
|
+
@package_dir = resolved_package
|
|
184
|
+
@test_files << format_file_with_line(file_path_only, line_number)
|
|
185
|
+
@argv.delete_at(index)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def handle_potential_package(arg, index)
|
|
189
|
+
return unless potential_package?(arg)
|
|
190
|
+
|
|
191
|
+
# Try to resolve as package (works for package names and explicit paths)
|
|
192
|
+
resolved_path = @package_resolver.resolve(arg)
|
|
193
|
+
if resolved_path
|
|
194
|
+
@package_dir = resolved_path
|
|
195
|
+
@argv.delete_at(index)
|
|
196
|
+
elsif explicit_path?(arg)
|
|
197
|
+
raise_package_not_found_error(arg)
|
|
198
|
+
elsif looks_like_package_name?(arg)
|
|
199
|
+
# Package-like name (e.g., ace-foo) that didn't resolve - give helpful error
|
|
200
|
+
raise_package_not_found_error(arg)
|
|
201
|
+
end
|
|
202
|
+
# Otherwise, fall through and let it be handled as target/file
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Check if argument looks like a package name (ace-* pattern)
|
|
206
|
+
def looks_like_package_name?(arg)
|
|
207
|
+
arg.start_with?("ace-") && !arg.include?("/")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Check if an argument could be a package name (not a target, file, or file:line)
|
|
211
|
+
def potential_package?(arg)
|
|
212
|
+
!known_target?(arg) &&
|
|
213
|
+
!(arg.end_with?(".rb") && File.file?(arg)) &&
|
|
214
|
+
!file_with_line?(arg)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def explicit_path?(arg)
|
|
218
|
+
arg.start_with?("./", "../", "/")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def file_with_line?(arg)
|
|
222
|
+
arg =~ /^(.+):(\d+)$/
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def handle_file_with_line(arg)
|
|
226
|
+
file_part, line_part = extract_file_and_line(arg)
|
|
227
|
+
|
|
228
|
+
# If we have a package_dir, make the path relative to it
|
|
229
|
+
check_path = @package_dir ? File.join(@package_dir, file_part) : file_part
|
|
230
|
+
|
|
231
|
+
unless File.exist?(check_path)
|
|
232
|
+
raise ArgumentError, "File not found: #{check_path}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Store the path that will work from the package directory
|
|
236
|
+
@test_files << (@package_dir ? format_file_with_line(file_part, line_part) : arg)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def existing_ruby_file?(arg)
|
|
240
|
+
File.exist?(arg) && arg.end_with?(".rb")
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def package_relative_ruby_file?(arg)
|
|
244
|
+
@package_dir && File.exist?(File.join(@package_dir, arg)) && arg.end_with?(".rb")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def raise_package_not_found_error(arg)
|
|
248
|
+
message = "Package not found: #{arg}\n"
|
|
249
|
+
message += if Dir.exist?(arg)
|
|
250
|
+
"Directory exists but has no test/ subdirectory.\n"
|
|
251
|
+
else
|
|
252
|
+
"Directory does not exist.\n"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
available = @package_resolver.available_packages
|
|
256
|
+
message += "Available packages: #{available.join(", ")}" if available.any?
|
|
257
|
+
|
|
258
|
+
raise ArgumentError, message
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|