ace-test-runner 0.27.1 → 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 +27 -0
- data/exe/ace-test-suite +13 -4
- 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 +2 -3
- data/lib/ace/test_runner/formatters/json_formatter.rb +3 -1
- data/lib/ace/test_runner/formatters/progress_file_formatter.rb +10 -7
- data/lib/ace/test_runner/formatters/progress_formatter.rb +10 -7
- data/lib/ace/test_runner/models/test_report.rb +1 -0
- data/lib/ace/test_runner/models/test_result.rb +12 -3
- 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/execution_evidence.rb +102 -0
- data/lib/ace/test_runner/molecules/failed_package_reporter.rb +4 -65
- data/lib/ace/test_runner/molecules/in_process_runner.rb +48 -37
- data/lib/ace/test_runner/molecules/pattern_resolver.rb +8 -5
- data/lib/ace/test_runner/molecules/report_storage.rb +24 -20
- 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 +11 -4
- data/lib/ace/test_runner/organisms/sequential_target_executor.rb +6 -7
- data/lib/ace/test_runner/organisms/test_orchestrator.rb +44 -21
- data/lib/ace/test_runner/suite/display_manager.rb +7 -6
- data/lib/ace/test_runner/suite/orchestrator.rb +5 -3
- data/lib/ace/test_runner/suite/process_monitor.rb +37 -119
- data/lib/ace/test_runner/suite/result_aggregator.rb +17 -89
- data/lib/ace/test_runner/suite/simple_display_manager.rb +2 -1
- data/lib/ace/test_runner/version.rb +1 -1
- data/lib/ace/test_runner.rb +1 -0
- metadata +34 -2
|
@@ -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
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "json"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module TestRunner
|
|
9
|
+
module Molecules
|
|
10
|
+
# The completion file is published last, after the optional report is complete.
|
|
11
|
+
# A suite reads this exact file once; latest is only a navigation convenience.
|
|
12
|
+
class ExecutionEvidence
|
|
13
|
+
CHANNEL = "ACE_TEST_EXECUTION"
|
|
14
|
+
COUNTS = %w[total passed failed errors skipped assertions].freeze
|
|
15
|
+
attr_reader :identity, :path, :package, :entry_id, :package_path
|
|
16
|
+
|
|
17
|
+
def initialize(package_path:, package: nil, entry_id: nil, identity: SecureRandom.uuid, path: nil)
|
|
18
|
+
@identity = identity
|
|
19
|
+
@package_path = File.realpath(package_path)
|
|
20
|
+
@package = package || File.basename(@package_path)
|
|
21
|
+
@entry_id = entry_id || identity
|
|
22
|
+
@path = path || File.join(@package_path, ".ace-local/test/completions", "#{identity}.json")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.for_invocation(package_path)
|
|
26
|
+
channel = ENV[CHANNEL]
|
|
27
|
+
return new(package_path: package_path) unless channel
|
|
28
|
+
|
|
29
|
+
data = JSON.parse(channel, symbolize_names: true)
|
|
30
|
+
raise Error, "Execution package path mismatch" unless File.realpath(package_path) == data[:package_path]
|
|
31
|
+
|
|
32
|
+
new(**data)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def channel
|
|
36
|
+
JSON.generate(identity: identity, path: path, package: package, entry_id: entry_id, package_path: package_path)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def publish(result, files:, report_dir: nil)
|
|
40
|
+
data = {
|
|
41
|
+
execution_id: identity, entry_id: entry_id, package: package, package_path: package_path,
|
|
42
|
+
pid: Process.pid, completed: true, selected_files: files, report_dir: report_dir,
|
|
43
|
+
total: result.total_tests, passed: result.passed, failed: result.failed, errors: result.errors,
|
|
44
|
+
skipped: result.skipped, assertions: result.assertions, duration: result.duration,
|
|
45
|
+
success: result.success?, error: result.execution_error
|
|
46
|
+
}
|
|
47
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
48
|
+
temporary = "#{path}.#{SecureRandom.uuid}.tmp"
|
|
49
|
+
File.write(temporary, JSON.generate(data))
|
|
50
|
+
# Exclusive publication: a completed invocation may never be replaced.
|
|
51
|
+
File.link(temporary, path)
|
|
52
|
+
data
|
|
53
|
+
ensure
|
|
54
|
+
FileUtils.rm_f(temporary) if temporary
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def read(pid:, save_reports:)
|
|
58
|
+
data = JSON.parse(File.read(path))
|
|
59
|
+
valid = data.is_a?(Hash) && data["completed"] == true &&
|
|
60
|
+
data["execution_id"] == identity && data["entry_id"] == entry_id &&
|
|
61
|
+
data["package"] == package && data["package_path"] == package_path && data["pid"] == pid &&
|
|
62
|
+
data["selected_files"].is_a?(Array) && data["selected_files"].all? { |file| file.is_a?(String) } &&
|
|
63
|
+
COUNTS.all? { |key| data[key].is_a?(Integer) && data[key] >= 0 } &&
|
|
64
|
+
[true, false].include?(data["success"]) && data["duration"].is_a?(Numeric) &&
|
|
65
|
+
data["duration"].finite? && data["duration"] >= 0 &&
|
|
66
|
+
data["total"] == data.values_at("passed", "failed", "errors", "skipped").sum &&
|
|
67
|
+
(!data["success"] || data["failed"] + data["errors"] == 0) &&
|
|
68
|
+
(!data["selected_files"].empty? || data["total"] == 0) &&
|
|
69
|
+
(!data["success"] || data["total"] > 0 || data["selected_files"].empty?)
|
|
70
|
+
raise Error, "Invalid or mismatched execution completion evidence" unless valid
|
|
71
|
+
|
|
72
|
+
if save_reports
|
|
73
|
+
validate_report!(data)
|
|
74
|
+
elsif data["report_dir"]
|
|
75
|
+
raise Error, "Unexpected saved report in no-save execution"
|
|
76
|
+
end
|
|
77
|
+
data.transform_keys(&:to_sym)
|
|
78
|
+
rescue SystemCallError, JSON::ParserError, TypeError => e
|
|
79
|
+
raise Error, "Unverifiable execution completion evidence: #{e.message}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def validate_report!(data)
|
|
85
|
+
directory = data["report_dir"]
|
|
86
|
+
raise Error, "Missing concrete execution report directory" unless directory.is_a?(String) &&
|
|
87
|
+
File.absolute_path(directory) == directory && File.basename(directory) == identity && !File.symlink?(directory)
|
|
88
|
+
|
|
89
|
+
summary = JSON.parse(File.read(File.join(directory, "summary.json")))
|
|
90
|
+
report = JSON.parse(File.read(File.join(directory, "report.json")))
|
|
91
|
+
raise Error, "Mismatched execution summary/report" unless summary.is_a?(Hash) && report.is_a?(Hash) &&
|
|
92
|
+
summary["execution_id"] == identity && report.dig("metadata", "execution_id") == identity &&
|
|
93
|
+
report["report_path"] == directory && report["files_tested"] == data["selected_files"] &&
|
|
94
|
+
report.dig("result", "total_tests") == data["total"] &&
|
|
95
|
+
COUNTS.all? { |key| summary[key] == data[key] } && summary["success"] == data["success"] &&
|
|
96
|
+
summary["duration"] == data["duration"] &&
|
|
97
|
+
%w[passed failed errors skipped assertions duration success].all? { |key| report.dig("result", key) == data[key] }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -10,79 +10,18 @@ module Ace
|
|
|
10
10
|
module_function
|
|
11
11
|
|
|
12
12
|
def format_for_display(package)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
package_name = extract_name(package) || File.basename(path)
|
|
16
|
-
report_path = Atoms::ReportPathResolver.call(
|
|
17
|
-
path,
|
|
18
|
-
report_root: report_root,
|
|
19
|
-
package_name: package_name
|
|
20
|
-
)
|
|
21
|
-
|
|
22
|
-
if report_path
|
|
23
|
-
begin
|
|
24
|
-
relative_path = Pathname.new(report_path).relative_path_from(Dir.pwd)
|
|
25
|
-
" → See #{relative_path}"
|
|
26
|
-
rescue => e
|
|
27
|
-
warn "Failed to calculate relative path: #{e.message}" if debug_mode?
|
|
28
|
-
" → See #{report_path}"
|
|
29
|
-
end
|
|
30
|
-
else
|
|
31
|
-
reports_path = fallback_reports_path(path, report_root, package_name)
|
|
32
|
-
begin
|
|
33
|
-
relative_path = Pathname.new(reports_path).relative_path_from(Dir.pwd)
|
|
34
|
-
" → Check #{relative_path}/ for details"
|
|
35
|
-
rescue => e
|
|
36
|
-
warn "Failed to calculate relative path: #{e.message}" if debug_mode?
|
|
37
|
-
" → Check #{reports_path}/ for details"
|
|
38
|
-
end
|
|
39
|
-
end
|
|
13
|
+
directory = package[:report_dir] || package["report_dir"]
|
|
14
|
+
directory ? " → See #{relative_or_absolute(directory)}" : " → No saved report"
|
|
40
15
|
end
|
|
41
16
|
|
|
42
17
|
def format_for_markdown(package)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
package_name = extract_name(package) || File.basename(path)
|
|
46
|
-
report_path = Atoms::ReportPathResolver.call(
|
|
47
|
-
path,
|
|
48
|
-
report_root: report_root,
|
|
49
|
-
package_name: package_name
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
if report_path
|
|
53
|
-
relative_report_path = relative_or_absolute(report_path)
|
|
54
|
-
"- Report: `#{relative_report_path}`"
|
|
55
|
-
else
|
|
56
|
-
reports_path = fallback_reports_path(path, report_root, package_name)
|
|
57
|
-
relative_reports_path = relative_or_absolute(reports_path)
|
|
58
|
-
"- Report: Check `#{relative_reports_path}/` for details"
|
|
59
|
-
end
|
|
18
|
+
directory = package[:report_dir] || package["report_dir"]
|
|
19
|
+
directory ? "- Report: `#{relative_or_absolute(directory)}`" : "- No saved report"
|
|
60
20
|
end
|
|
61
21
|
|
|
62
22
|
class << self
|
|
63
23
|
private
|
|
64
24
|
|
|
65
|
-
def extract_path(package)
|
|
66
|
-
package[:path] || package["path"]
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def extract_name(package)
|
|
70
|
-
package[:name] || package["name"]
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def extract_report_root(package)
|
|
74
|
-
package[:report_root] || package["report_root"]
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def fallback_reports_path(path, report_root, package_name)
|
|
78
|
-
if report_root
|
|
79
|
-
short_name = package_name.to_s.sub(/\Aace-/, "")
|
|
80
|
-
return File.join(report_root, short_name) unless short_name.empty?
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
File.join(path, "test-reports")
|
|
84
|
-
end
|
|
85
|
-
|
|
86
25
|
def debug_mode?
|
|
87
26
|
ENV["DEBUG"]
|
|
88
27
|
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
|
|
|
@@ -10,6 +11,16 @@ module Ace
|
|
|
10
11
|
# Runs tests directly in the current Ruby process without spawning subprocesses
|
|
11
12
|
# This provides significantly faster execution for unit tests that don't need isolation
|
|
12
13
|
class InProcessRunner
|
|
14
|
+
# Minitest converts ordinary timeout exceptions into completed test
|
|
15
|
+
# errors. Its pass-through SystemExit boundary preserves execution
|
|
16
|
+
# deadlines; this owner catches the private control-flow exception.
|
|
17
|
+
class ExecutionTimeout < SystemExit
|
|
18
|
+
def initialize(*)
|
|
19
|
+
super(124)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
private_constant :ExecutionTimeout
|
|
23
|
+
|
|
13
24
|
def initialize(timeout: nil, launch_env:)
|
|
14
25
|
@timeout = timeout
|
|
15
26
|
@launch_env = launch_env # Hermetic fixture environment applied for the test run
|
|
@@ -52,30 +63,11 @@ module Ace
|
|
|
52
63
|
require "minitest/autorun"
|
|
53
64
|
end
|
|
54
65
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
files.each do |file|
|
|
61
|
-
# Check if file has line number (file:line format)
|
|
62
|
-
if file =~ /^(.+):(\d+)$/
|
|
63
|
-
actual_file = $1
|
|
64
|
-
line_number = $2.to_i
|
|
65
|
-
|
|
66
|
-
# Resolve line number to test name
|
|
67
|
-
require_relative "../atoms/line_number_resolver"
|
|
68
|
-
test_name = Ace::TestRunner::Atoms::LineNumberResolver.resolve_test_at_line(actual_file, line_number)
|
|
69
|
-
test_names_to_run << test_name if test_name
|
|
70
|
-
|
|
71
|
-
files_to_load << actual_file
|
|
72
|
-
else
|
|
73
|
-
files_to_load << file
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
# Store test names in options to pass to run_minitest_with_args
|
|
78
|
-
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
|
|
79
71
|
|
|
80
72
|
# Clear previously loaded test classes to avoid accumulation between groups
|
|
81
73
|
# This is crucial for in-process execution where tests from previous groups
|
|
@@ -118,18 +110,27 @@ module Ace
|
|
|
118
110
|
end
|
|
119
111
|
end
|
|
120
112
|
|
|
113
|
+
if selection.qualified?
|
|
114
|
+
options = options.merge(selection_pattern: SelectionVerifier.verify_loaded!(selection))
|
|
115
|
+
end
|
|
116
|
+
|
|
121
117
|
# Run Minitest with captured output
|
|
122
118
|
# Suppress Minitest's own output by using null reporter
|
|
123
|
-
|
|
124
|
-
|
|
119
|
+
execution = lambda do
|
|
120
|
+
if @timeout
|
|
121
|
+
Timeout.timeout(@timeout, ExecutionTimeout) { run_minitest_silent(options) }
|
|
122
|
+
else
|
|
125
123
|
run_minitest_silent(options)
|
|
126
124
|
end
|
|
125
|
+
end
|
|
126
|
+
exit_code = if selection.qualified?
|
|
127
|
+
SelectionVerifier.verify_execution!(selection, &execution)
|
|
127
128
|
else
|
|
128
|
-
|
|
129
|
+
execution.call
|
|
129
130
|
end
|
|
130
131
|
|
|
131
132
|
success = exit_code == true || exit_code == 0
|
|
132
|
-
rescue Timeout::Error
|
|
133
|
+
rescue ExecutionTimeout, Timeout::Error
|
|
133
134
|
stderr_io.puts "Test execution timed out after #{@timeout} seconds"
|
|
134
135
|
success = false
|
|
135
136
|
exit_code = 124
|
|
@@ -232,9 +233,8 @@ module Ace
|
|
|
232
233
|
args << "--verbose" if options[:verbose]
|
|
233
234
|
|
|
234
235
|
# Add test name filter if line numbers were provided
|
|
235
|
-
if options[:
|
|
236
|
-
|
|
237
|
-
args << "--name" << "/#{pattern}/"
|
|
236
|
+
if options[:selection_pattern]
|
|
237
|
+
args << "--name" << "/#{options[:selection_pattern]}/"
|
|
238
238
|
end
|
|
239
239
|
|
|
240
240
|
# Run Minitest
|
|
@@ -251,14 +251,25 @@ module Ace
|
|
|
251
251
|
args << "--seed" << options[:seed].to_s if options[:seed]
|
|
252
252
|
|
|
253
253
|
# Add test name filter if line numbers were provided
|
|
254
|
-
if options[:
|
|
255
|
-
|
|
256
|
-
args << "--name" << "/#{pattern}/"
|
|
254
|
+
if options[:selection_pattern]
|
|
255
|
+
args << "--name" << "/#{options[:selection_pattern]}/"
|
|
257
256
|
end
|
|
258
257
|
|
|
259
|
-
#
|
|
260
|
-
#
|
|
261
|
-
|
|
258
|
+
# Minitest swallows Interrupt and returns a normal failure. Preserve
|
|
259
|
+
# operator SIGINT as interruption so the owner never writes a completed
|
|
260
|
+
# report for this invocation.
|
|
261
|
+
interrupted = false
|
|
262
|
+
previous_handler = Signal.trap("INT") do
|
|
263
|
+
interrupted = true
|
|
264
|
+
raise Interrupt
|
|
265
|
+
end
|
|
266
|
+
begin
|
|
267
|
+
result = Minitest.run(args)
|
|
268
|
+
raise Interrupt if interrupted
|
|
269
|
+
result
|
|
270
|
+
ensure
|
|
271
|
+
Signal.trap("INT", previous_handler)
|
|
272
|
+
end
|
|
262
273
|
end
|
|
263
274
|
end
|
|
264
275
|
end
|
|
@@ -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
|
|
@@ -1,22 +1,33 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
3
5
|
module Ace
|
|
4
6
|
module TestRunner
|
|
5
7
|
module Molecules
|
|
6
8
|
# Handles storage of test reports to filesystem
|
|
7
9
|
class ReportStorage
|
|
8
|
-
def initialize(base_dir: ".ace-local/test/reports"
|
|
10
|
+
def initialize(base_dir: ".ace-local/test/reports")
|
|
9
11
|
@base_dir = base_dir
|
|
10
|
-
@timestamp_generator = timestamp_generator || Atoms::TimestampGenerator.new
|
|
11
12
|
end
|
|
12
13
|
|
|
13
|
-
def
|
|
14
|
+
def reserve(execution_id = SecureRandom.uuid)
|
|
15
|
+
ensure_base_directory
|
|
16
|
+
directory = File.join(File.expand_path(@base_dir), execution_id)
|
|
17
|
+
Dir.mkdir(directory)
|
|
18
|
+
directory
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def save_report(report, format: :json, report_dir: nil)
|
|
14
22
|
ensure_base_directory
|
|
15
|
-
report_dir
|
|
23
|
+
report_dir ||= reserve
|
|
24
|
+
report.report_path = report_dir
|
|
25
|
+
report.metadata[:execution_id] = File.basename(report_dir)
|
|
16
26
|
|
|
17
27
|
case format
|
|
18
28
|
when :json
|
|
19
29
|
save_json_report(report, report_dir)
|
|
30
|
+
save_summary(report.result, report_dir)
|
|
20
31
|
when :markdown
|
|
21
32
|
save_markdown_report(report, report_dir)
|
|
22
33
|
when :all
|
|
@@ -50,6 +61,8 @@ module Ace
|
|
|
50
61
|
summary_file = File.join(report_dir, "summary.json")
|
|
51
62
|
|
|
52
63
|
summary_data = {
|
|
64
|
+
execution_id: File.basename(report_dir),
|
|
65
|
+
assertions: result.assertions,
|
|
53
66
|
passed: result.passed,
|
|
54
67
|
failed: result.failed,
|
|
55
68
|
errors: result.errors,
|
|
@@ -61,7 +74,7 @@ module Ace
|
|
|
61
74
|
timestamp: Time.now.iso8601
|
|
62
75
|
}
|
|
63
76
|
|
|
64
|
-
File.
|
|
77
|
+
File.open(summary_file, "wx") { |file| file.write(JSON.pretty_generate(summary_data)) }
|
|
65
78
|
summary_file
|
|
66
79
|
end
|
|
67
80
|
|
|
@@ -211,27 +224,18 @@ module Ace
|
|
|
211
224
|
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
212
225
|
end
|
|
213
226
|
|
|
214
|
-
def create_report_directory
|
|
215
|
-
ensure_base_directory
|
|
216
|
-
timestamp = @timestamp_generator.directory_name
|
|
217
|
-
report_dir = File.join(@base_dir, timestamp)
|
|
218
|
-
FileUtils.mkdir_p(report_dir)
|
|
219
|
-
report_dir
|
|
220
|
-
end
|
|
221
|
-
|
|
222
227
|
def create_latest_symlink(report_dir)
|
|
223
228
|
latest_link = File.join(@base_dir, "latest")
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
FileUtils.ln_s(File.basename(report_dir), latest_link)
|
|
229
|
+
temporary = "#{latest_link}.#{SecureRandom.uuid}"
|
|
230
|
+
File.symlink(File.basename(report_dir), temporary)
|
|
231
|
+
File.rename(temporary, latest_link)
|
|
232
|
+
ensure
|
|
233
|
+
FileUtils.rm_f(temporary) if temporary
|
|
230
234
|
end
|
|
231
235
|
|
|
232
236
|
def save_json_report(report, report_dir)
|
|
233
237
|
report_file = File.join(report_dir, "report.json")
|
|
234
|
-
File.
|
|
238
|
+
File.open(report_file, "wx") { |file| file.write(report.to_json) }
|
|
235
239
|
report_file
|
|
236
240
|
end
|
|
237
241
|
|
|
@@ -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
|