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
|
@@ -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?
|
|
@@ -139,6 +145,7 @@ module Ace
|
|
|
139
145
|
stdout: results.map { |r| r[:stdout] }.join("\n"),
|
|
140
146
|
stderr: results.map { |r| r[:stderr] }.join("\n"),
|
|
141
147
|
commands: results.map { |r| r[:command] },
|
|
148
|
+
execution_outputs: results.map { |r| r[:stdout] },
|
|
142
149
|
start_time: results.first[:start_time],
|
|
143
150
|
end_time: results.last[:end_time],
|
|
144
151
|
duration: results.sum { |r| r[:duration] },
|
|
@@ -37,7 +37,7 @@ module Ace
|
|
|
37
37
|
# Parse result
|
|
38
38
|
parsed = if result[:commands] && result[:commands].is_a?(Array)
|
|
39
39
|
# Multiple commands executed (per-file)
|
|
40
|
-
aggregate_results(result
|
|
40
|
+
aggregate_results(result.fetch(:execution_outputs), @result_parser)
|
|
41
41
|
else
|
|
42
42
|
@result_parser.parse_output(result[:stdout])
|
|
43
43
|
end
|
|
@@ -81,10 +81,8 @@ module Ace
|
|
|
81
81
|
|
|
82
82
|
private
|
|
83
83
|
|
|
84
|
-
def aggregate_results(
|
|
85
|
-
#
|
|
86
|
-
individual_outputs = combined_output.split(/^Started with run options/)
|
|
87
|
-
individual_outputs.shift if individual_outputs.first && individual_outputs.first.empty?
|
|
84
|
+
def aggregate_results(individual_outputs, parser)
|
|
85
|
+
# Preserve exact executor boundaries, including headerless partial output.
|
|
88
86
|
|
|
89
87
|
aggregated = {
|
|
90
88
|
summary: {
|
|
@@ -102,7 +100,6 @@ module Ace
|
|
|
102
100
|
}
|
|
103
101
|
|
|
104
102
|
individual_outputs.each do |output|
|
|
105
|
-
output = "Started with run options" + output
|
|
106
103
|
parsed = parser.parse_output(output)
|
|
107
104
|
|
|
108
105
|
aggregated[:summary][:runs] += parsed[:summary][:runs]
|
|
@@ -160,7 +157,8 @@ module Ace
|
|
|
160
157
|
combined_stderr = all_results.map { |r| r[:stderr] }.join("\n")
|
|
161
158
|
|
|
162
159
|
# Determine success
|
|
163
|
-
success =
|
|
160
|
+
success = all_results.all? { |result| result[:success] == true } &&
|
|
161
|
+
total_summary[:failures] == 0 && total_summary[:errors] == 0
|
|
164
162
|
|
|
165
163
|
{
|
|
166
164
|
stdout: combined_stdout,
|
|
@@ -175,6 +173,7 @@ module Ace
|
|
|
175
173
|
duration: total_duration,
|
|
176
174
|
test_times: all_test_times
|
|
177
175
|
},
|
|
176
|
+
files: all_files.uniq,
|
|
178
177
|
stopped_at_target: stopped
|
|
179
178
|
}
|
|
180
179
|
end
|
|
@@ -60,6 +60,7 @@ module Ace
|
|
|
60
60
|
validate_configuration!
|
|
61
61
|
|
|
62
62
|
start_time = Time.now
|
|
63
|
+
@execution_evidence = Molecules::ExecutionEvidence.for_invocation(Dir.pwd)
|
|
63
64
|
|
|
64
65
|
# Print package context if running in different directory
|
|
65
66
|
if @package_dir
|
|
@@ -82,6 +83,7 @@ module Ace
|
|
|
82
83
|
end
|
|
83
84
|
|
|
84
85
|
resolve_and_set_report_dir_context(test_files)
|
|
86
|
+
reserve_execution_report
|
|
85
87
|
|
|
86
88
|
# Count total available test files (before filtering)
|
|
87
89
|
total_available = count_total_test_files
|
|
@@ -104,22 +106,18 @@ module Ace
|
|
|
104
106
|
runs: 0,
|
|
105
107
|
assertions: 0,
|
|
106
108
|
failures: 0,
|
|
107
|
-
errors:
|
|
109
|
+
errors: 0, # Execution failure is distinct from completed test errors
|
|
108
110
|
skips: 0,
|
|
109
111
|
passed: 0
|
|
110
112
|
},
|
|
111
113
|
failures: [],
|
|
112
|
-
errors: [
|
|
113
|
-
message: execution_result[:stderr].strip,
|
|
114
|
-
type: "LoadError",
|
|
115
|
-
files: test_files
|
|
116
|
-
}],
|
|
114
|
+
errors: [],
|
|
117
115
|
deprecations: [],
|
|
118
116
|
duration: execution_result[:duration]
|
|
119
117
|
}
|
|
120
118
|
elsif execution_result[:commands] && execution_result[:commands].is_a?(Array)
|
|
121
119
|
# Each file was executed separately, parse and sum them all
|
|
122
|
-
aggregate_individual_results(execution_result
|
|
120
|
+
aggregate_individual_results(execution_result.fetch(:execution_outputs))
|
|
123
121
|
else
|
|
124
122
|
# Single command execution (by-target)
|
|
125
123
|
@result_parser.parse_output(execution_result[:stdout])
|
|
@@ -127,6 +125,7 @@ module Ace
|
|
|
127
125
|
|
|
128
126
|
# Build result object
|
|
129
127
|
@result = build_result(@parsed_result, execution_result, start_time)
|
|
128
|
+
validate_selected_execution
|
|
130
129
|
|
|
131
130
|
# Analyze failures and errors
|
|
132
131
|
if @result.has_failures?
|
|
@@ -165,6 +164,9 @@ module Ace
|
|
|
165
164
|
@formatter.report_path = report_path if @formatter.respond_to?(:report_path=)
|
|
166
165
|
end
|
|
167
166
|
|
|
167
|
+
# Publish completion only after all optional report files are written.
|
|
168
|
+
@execution_evidence.publish(@result, files: report.files_tested, report_dir: @execution_report_dir)
|
|
169
|
+
|
|
168
170
|
# Output to stdout
|
|
169
171
|
@formatter.on_finish(@result)
|
|
170
172
|
|
|
@@ -231,6 +233,7 @@ module Ace
|
|
|
231
233
|
all_files = targets.flat_map { |t| t[:files] }
|
|
232
234
|
|
|
233
235
|
resolve_and_set_report_dir_context(all_files)
|
|
236
|
+
reserve_execution_report
|
|
234
237
|
|
|
235
238
|
total_available = count_total_test_files
|
|
236
239
|
|
|
@@ -262,11 +265,15 @@ module Ace
|
|
|
262
265
|
end
|
|
263
266
|
end
|
|
264
267
|
|
|
268
|
+
# Only dispatched targets belong in the execution report after an early stop.
|
|
269
|
+
all_files = execution_result.fetch(:files)
|
|
270
|
+
|
|
265
271
|
# Use the parsed result from sequential executor
|
|
266
272
|
@parsed_result = execution_result[:parsed_result]
|
|
267
273
|
|
|
268
274
|
# Build result object
|
|
269
275
|
@result = build_result(@parsed_result, execution_result, start_time)
|
|
276
|
+
validate_selected_execution
|
|
270
277
|
|
|
271
278
|
# Analyze failures
|
|
272
279
|
if @result.has_failures?
|
|
@@ -301,6 +308,9 @@ module Ace
|
|
|
301
308
|
@formatter.report_path = report_path if @formatter.respond_to?(:report_path=)
|
|
302
309
|
end
|
|
303
310
|
|
|
311
|
+
# Publish completion only after all optional report files are written.
|
|
312
|
+
@execution_evidence.publish(@result, files: report.files_tested, report_dir: @execution_report_dir)
|
|
313
|
+
|
|
304
314
|
# Output to stdout
|
|
305
315
|
@formatter.on_finish(@result)
|
|
306
316
|
|
|
@@ -463,6 +473,10 @@ module Ace
|
|
|
463
473
|
|
|
464
474
|
def handle_no_tests
|
|
465
475
|
@result = Models::TestResult.new
|
|
476
|
+
resolve_and_set_report_dir_context([])
|
|
477
|
+
reserve_execution_report
|
|
478
|
+
save_reports(@report_generator.generate(@result, [])) if @configuration.save_reports
|
|
479
|
+
@execution_evidence.publish(@result, files: [], report_dir: @execution_report_dir)
|
|
466
480
|
|
|
467
481
|
message = if @configuration.filter
|
|
468
482
|
"No test files found matching pattern '#{@configuration.filter}'"
|
|
@@ -471,6 +485,7 @@ module Ace
|
|
|
471
485
|
end
|
|
472
486
|
|
|
473
487
|
puts message
|
|
488
|
+
puts(@execution_report_dir ? "Details: #{@execution_report_dir}/" : "No saved report")
|
|
474
489
|
0
|
|
475
490
|
end
|
|
476
491
|
|
|
@@ -507,6 +522,13 @@ module Ace
|
|
|
507
522
|
end
|
|
508
523
|
end
|
|
509
524
|
|
|
525
|
+
def validate_selected_execution
|
|
526
|
+
return unless @result.success? && @result.total_tests == 0
|
|
527
|
+
|
|
528
|
+
@result.execution_success = false
|
|
529
|
+
@result.stderr = [@result.stderr, "Selected test files produced no executed tests"].reject(&:empty?).join("\n")
|
|
530
|
+
end
|
|
531
|
+
|
|
510
532
|
def build_result(parsed_result, execution_result, start_time)
|
|
511
533
|
Models::TestResult.new(
|
|
512
534
|
passed: parsed_result[:summary][:passed],
|
|
@@ -519,17 +541,16 @@ module Ace
|
|
|
519
541
|
end_time: Time.now,
|
|
520
542
|
deprecations: parsed_result[:deprecations],
|
|
521
543
|
raw_output: execution_result[:stdout],
|
|
522
|
-
stderr: execution_result[:stderr]
|
|
544
|
+
stderr: execution_result[:stderr],
|
|
545
|
+
execution_success: execution_result[:success] == true
|
|
523
546
|
)
|
|
524
547
|
end
|
|
525
548
|
|
|
526
|
-
def aggregate_individual_results(
|
|
527
|
-
#
|
|
528
|
-
individual_outputs = combined_output.split(/^Started with run options/)
|
|
529
|
-
individual_outputs.shift if individual_outputs.first && individual_outputs.first.empty?
|
|
549
|
+
def aggregate_individual_results(individual_outputs)
|
|
550
|
+
# Process boundaries come from the executor, never text inside stdout.
|
|
530
551
|
|
|
531
552
|
aggregated = {
|
|
532
|
-
raw_output:
|
|
553
|
+
raw_output: individual_outputs.join("\n"),
|
|
533
554
|
summary: {
|
|
534
555
|
runs: 0,
|
|
535
556
|
assertions: 0,
|
|
@@ -545,7 +566,6 @@ module Ace
|
|
|
545
566
|
}
|
|
546
567
|
|
|
547
568
|
individual_outputs.each do |output|
|
|
548
|
-
output = "Started with run options" + output # Restore the split text
|
|
549
569
|
parsed = @result_parser.parse_output(output)
|
|
550
570
|
|
|
551
571
|
# Sum up the counts
|
|
@@ -602,19 +622,22 @@ module Ace
|
|
|
602
622
|
)
|
|
603
623
|
end
|
|
604
624
|
|
|
625
|
+
def reserve_execution_report
|
|
626
|
+
return unless @configuration.save_reports
|
|
627
|
+
|
|
628
|
+
@report_storage = Molecules::ReportStorage.new(base_dir: @configuration.report_dir)
|
|
629
|
+
@execution_report_dir = @report_storage.reserve(@execution_evidence.identity)
|
|
630
|
+
end
|
|
631
|
+
|
|
605
632
|
def save_reports(report)
|
|
606
|
-
|
|
607
|
-
storage = Molecules::ReportStorage.new(
|
|
608
|
-
base_dir: @configuration.report_dir,
|
|
609
|
-
timestamp_generator: timestamp_generator
|
|
610
|
-
)
|
|
633
|
+
storage = @report_storage
|
|
611
634
|
|
|
612
635
|
# Save in appropriate format
|
|
613
636
|
report_path = case @configuration.format
|
|
614
637
|
when "json"
|
|
615
|
-
storage.save_report(report, format: :json)
|
|
638
|
+
storage.save_report(report, format: :json, report_dir: @execution_report_dir)
|
|
616
639
|
else
|
|
617
|
-
storage.save_report(report, format: :all)
|
|
640
|
+
storage.save_report(report, format: :all, report_dir: @execution_report_dir)
|
|
618
641
|
end
|
|
619
642
|
|
|
620
643
|
# Always save raw output
|
|
@@ -33,9 +33,9 @@ module Ace
|
|
|
33
33
|
|
|
34
34
|
# Reserve lines for each package
|
|
35
35
|
@packages.each_with_index do |package, index|
|
|
36
|
-
@lines[package["
|
|
37
|
-
@package_status[package["
|
|
38
|
-
print_package_line(package["
|
|
36
|
+
@lines[package["entry_id"]] = index + 5 # Account for header lines
|
|
37
|
+
@package_status[package["entry_id"]] = {status: :waiting}
|
|
38
|
+
print_package_line(package["entry_id"])
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
# Print footer space
|
|
@@ -45,8 +45,8 @@ module Ace
|
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
def update_package(package, status, output = nil)
|
|
48
|
-
@package_status[package["
|
|
49
|
-
print_package_line(package["
|
|
48
|
+
@package_status[package["entry_id"]] = status
|
|
49
|
+
print_package_line(package["entry_id"])
|
|
50
50
|
update_footer
|
|
51
51
|
end
|
|
52
52
|
|
|
@@ -87,7 +87,7 @@ module Ace
|
|
|
87
87
|
print "\033[K" # Clear line
|
|
88
88
|
|
|
89
89
|
# Format package name (fixed width, no brackets)
|
|
90
|
-
pkg_name = name.ljust(25)
|
|
90
|
+
pkg_name = @packages.find { |package| package["entry_id"] == name }.fetch("name").ljust(25)
|
|
91
91
|
|
|
92
92
|
case status[:status]
|
|
93
93
|
when :waiting
|
|
@@ -135,6 +135,7 @@ module Ace
|
|
|
135
135
|
line_text = "#{icon} #{elapsed} #{pkg_name} #{tests_col} #{asserts_col} #{fail_col}"
|
|
136
136
|
line_text += " #{skipped} skip" if skipped > 0
|
|
137
137
|
|
|
138
|
+
line_text += results[:report_dir] ? " report: #{results[:report_dir]}" : " no saved report"
|
|
138
139
|
print line_text
|
|
139
140
|
end
|
|
140
141
|
end
|
|
@@ -11,7 +11,9 @@ module Ace
|
|
|
11
11
|
|
|
12
12
|
def initialize(config)
|
|
13
13
|
@config = config
|
|
14
|
-
@packages = config.dig("test_suite", "packages") || []
|
|
14
|
+
@packages = (config.dig("test_suite", "packages") || []).map do |package|
|
|
15
|
+
package.merge("entry_id" => SecureRandom.uuid)
|
|
16
|
+
end
|
|
15
17
|
@results = {}
|
|
16
18
|
@running_processes = {}
|
|
17
19
|
@completed_packages = []
|
|
@@ -59,7 +61,7 @@ module Ace
|
|
|
59
61
|
|
|
60
62
|
if status[:completed]
|
|
61
63
|
@completed_packages << pkg
|
|
62
|
-
@results[pkg["
|
|
64
|
+
@results[pkg["entry_id"]] = status
|
|
63
65
|
end
|
|
64
66
|
end
|
|
65
67
|
end
|
|
@@ -75,7 +77,7 @@ module Ace
|
|
|
75
77
|
display_manager.show_final_results
|
|
76
78
|
|
|
77
79
|
# Aggregate results
|
|
78
|
-
aggregator = ResultAggregator.new(@packages,
|
|
80
|
+
aggregator = ResultAggregator.new(@packages, runtime_results: @results)
|
|
79
81
|
summary = aggregator.aggregate
|
|
80
82
|
|
|
81
83
|
display_manager.show_summary(summary)
|
|
@@ -30,6 +30,10 @@ module Ace
|
|
|
30
30
|
return
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
package["entry_id"] ||= SecureRandom.uuid
|
|
34
|
+
evidence = Molecules::ExecutionEvidence.new(package_path: package["path"],
|
|
35
|
+
package: package["name"], entry_id: package["entry_id"])
|
|
36
|
+
|
|
33
37
|
# Build command
|
|
34
38
|
cmd = build_command(package, test_options)
|
|
35
39
|
|
|
@@ -45,12 +49,16 @@ module Ace
|
|
|
45
49
|
fixture_environment.env[Models::EnvironmentPolicy::SUITE_CHANNEL_KEY] =
|
|
46
50
|
@environment_policy.channel_payload
|
|
47
51
|
|
|
52
|
+
fixture_environment.env[Molecules::ExecutionEvidence::CHANNEL] = evidence.channel
|
|
53
|
+
|
|
48
54
|
start_time = now
|
|
49
55
|
stdin, stdout, stderr, thread = Open3.popen3(
|
|
50
56
|
fixture_environment.env, *cmd, chdir: package["path"], pgroup: true, unsetenv_others: true
|
|
51
57
|
)
|
|
52
58
|
|
|
53
|
-
@processes[package["
|
|
59
|
+
@processes[package["entry_id"]] = {
|
|
60
|
+
evidence: evidence,
|
|
61
|
+
save_reports: test_options.fetch("save_reports", true),
|
|
54
62
|
package: package,
|
|
55
63
|
thread: thread,
|
|
56
64
|
stdout: stdout,
|
|
@@ -61,7 +69,6 @@ module Ace
|
|
|
61
69
|
fixture_environment: fixture_environment,
|
|
62
70
|
output: +"",
|
|
63
71
|
stderr_output: +"",
|
|
64
|
-
report_root: test_options["report_dir"],
|
|
65
72
|
test_count: 0,
|
|
66
73
|
tests_run: 0,
|
|
67
74
|
dots: +"",
|
|
@@ -114,9 +121,8 @@ module Ace
|
|
|
114
121
|
|
|
115
122
|
# Final callback
|
|
116
123
|
if callback
|
|
117
|
-
#
|
|
118
|
-
|
|
119
|
-
success_status = (!results[:success].nil?) ? results[:success] : (exit_status == 0)
|
|
124
|
+
# Completion evidence and child outcome must both permit success.
|
|
125
|
+
success_status = results[:success] == true
|
|
120
126
|
|
|
121
127
|
callback.call(package, {
|
|
122
128
|
status: :completed,
|
|
@@ -174,11 +180,14 @@ module Ace
|
|
|
174
180
|
rescue Timeout::Error, StandardError
|
|
175
181
|
nil
|
|
176
182
|
end
|
|
183
|
+
end
|
|
177
184
|
|
|
185
|
+
# Capture trustworthy partial counts and interruption outcome before cleanup.
|
|
186
|
+
check_processes
|
|
187
|
+
@processes.each_value do |process_info|
|
|
178
188
|
close_streams(process_info)
|
|
179
189
|
process_info[:fixture_environment]&.cleanup
|
|
180
190
|
end
|
|
181
|
-
|
|
182
191
|
@processes.clear
|
|
183
192
|
@completed.clear
|
|
184
193
|
end
|
|
@@ -213,7 +222,7 @@ module Ace
|
|
|
213
222
|
cmd_parts << "--format" << format
|
|
214
223
|
|
|
215
224
|
# Add other options
|
|
216
|
-
cmd_parts << "--no-save" unless options
|
|
225
|
+
cmd_parts << "--no-save-reports" unless options.fetch("save_reports", true)
|
|
217
226
|
cmd_parts << "--fail-fast" if options["fail_fast"]
|
|
218
227
|
cmd_parts << "--no-color" unless options.fetch("color", true)
|
|
219
228
|
if options["report_dir"]
|
|
@@ -304,98 +313,31 @@ module Ace
|
|
|
304
313
|
end
|
|
305
314
|
|
|
306
315
|
def build_results(process_info, elapsed, exit_status)
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
success
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
def interrupted_results(elapsed)
|
|
329
|
-
{
|
|
330
|
-
tests: 0,
|
|
331
|
-
assertions: 0,
|
|
332
|
-
failures: 0,
|
|
333
|
-
errors: 1,
|
|
334
|
-
duration: elapsed,
|
|
335
|
-
success: false,
|
|
336
|
-
error: "Interrupted before completion"
|
|
337
|
-
}
|
|
338
|
-
end
|
|
339
|
-
|
|
340
|
-
def failed_process_results(process_info, elapsed, exit_status, fresh_summary)
|
|
341
|
-
return fresh_summary if fresh_summary && fresh_summary[:success] == false
|
|
342
|
-
|
|
343
|
-
parsed = parse_results([process_info[:output], process_info[:stderr_output]].join("\n"))
|
|
344
|
-
parsed[:duration] ||= elapsed
|
|
345
|
-
parsed[:success] = false
|
|
346
|
-
parsed[:error] ||= failure_message_for(process_info, exit_status)
|
|
347
|
-
parsed[:errors] = 1 if parsed[:failures].to_i == 0 && parsed[:errors].to_i == 0
|
|
348
|
-
parsed
|
|
316
|
+
snapshot = begin
|
|
317
|
+
process_info[:evidence].read(pid: process_info[:pid], save_reports: process_info[:save_reports])
|
|
318
|
+
rescue Ace::TestRunner::Error => e
|
|
319
|
+
{total: 0, passed: 0, failed: 0, errors: 1, skipped: 0, assertions: 0,
|
|
320
|
+
duration: elapsed, success: false, error: e.message}
|
|
321
|
+
end
|
|
322
|
+
results = snapshot.merge(tests: snapshot[:total], failures: snapshot[:failed],
|
|
323
|
+
execution_id: process_info[:evidence].identity, completion_path: process_info[:evidence].path)
|
|
324
|
+
reason = if process_info[:timeout_triggered]
|
|
325
|
+
"Timed out after #{process_info[:timeout]} seconds"
|
|
326
|
+
elsif process_info[:terminated_by] == :interrupt
|
|
327
|
+
"Interrupted before completion"
|
|
328
|
+
elsif exit_status != 0
|
|
329
|
+
failure_message_for(process_info, exit_status)
|
|
330
|
+
end
|
|
331
|
+
if reason
|
|
332
|
+
results[:success] = false
|
|
333
|
+
results[:error] = reason
|
|
334
|
+
end
|
|
335
|
+
results.freeze
|
|
349
336
|
end
|
|
350
337
|
|
|
351
338
|
def failure_message_for(process_info, exit_status)
|
|
352
339
|
stderr = process_info[:stderr_output].to_s.strip
|
|
353
|
-
|
|
354
|
-
message = stderr.empty? ? stdout : stderr
|
|
355
|
-
return message unless message.empty?
|
|
356
|
-
|
|
357
|
-
"ace-test exited with status #{exit_status}"
|
|
358
|
-
end
|
|
359
|
-
|
|
360
|
-
def load_summary_results(process_info, elapsed)
|
|
361
|
-
package = process_info[:package]
|
|
362
|
-
reports_dir = Atoms::ReportPathResolver.report_directory(
|
|
363
|
-
package["path"],
|
|
364
|
-
report_root: process_info[:report_root],
|
|
365
|
-
package_name: package["name"]
|
|
366
|
-
)
|
|
367
|
-
summary_file = reports_dir ? File.join(reports_dir, "summary.json") : nil
|
|
368
|
-
return nil unless summary_file && File.exist?(summary_file)
|
|
369
|
-
return nil unless summary_fresh_for_run?(summary_file, process_info[:start_time])
|
|
370
|
-
|
|
371
|
-
json_data = JSON.parse(File.read(summary_file))
|
|
372
|
-
results = {
|
|
373
|
-
tests: json_data["total"] || 0,
|
|
374
|
-
assertions: json_data["assertions"] || 0,
|
|
375
|
-
failures: json_data["failed"] || 0,
|
|
376
|
-
errors: json_data["errors"] || 0,
|
|
377
|
-
skipped: json_data["skipped"] || 0,
|
|
378
|
-
duration: json_data["duration"] || elapsed,
|
|
379
|
-
success: json_data["success"] || false
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
if results[:assertions] == 0
|
|
383
|
-
report_file = File.join(reports_dir, "report.json")
|
|
384
|
-
if File.exist?(report_file)
|
|
385
|
-
report_data = JSON.parse(File.read(report_file))
|
|
386
|
-
results[:assertions] = report_data.dig("result", "assertions") || 0
|
|
387
|
-
end
|
|
388
|
-
end
|
|
389
|
-
|
|
390
|
-
results
|
|
391
|
-
rescue JSON::ParserError
|
|
392
|
-
nil
|
|
393
|
-
end
|
|
394
|
-
|
|
395
|
-
def summary_fresh_for_run?(summary_file, start_time)
|
|
396
|
-
File.mtime(summary_file) >= (start_time - 0.001)
|
|
397
|
-
rescue StandardError
|
|
398
|
-
false
|
|
340
|
+
stderr.empty? ? "ace-test exited with status #{exit_status.inspect}" : stderr
|
|
399
341
|
end
|
|
400
342
|
|
|
401
343
|
def parse_progress(process_info, chunk)
|
|
@@ -411,30 +353,6 @@ module Ace
|
|
|
411
353
|
process_info[:test_count] = $1.to_i
|
|
412
354
|
end
|
|
413
355
|
end
|
|
414
|
-
|
|
415
|
-
def parse_results(output)
|
|
416
|
-
# Look for summary line in output
|
|
417
|
-
if output =~ /(\d+) tests?, (\d+) assertions?, (\d+) failures?, (\d+) errors? \(([\d.]+)s\)/
|
|
418
|
-
{
|
|
419
|
-
tests: $1.to_i,
|
|
420
|
-
assertions: $2.to_i,
|
|
421
|
-
failures: $3.to_i,
|
|
422
|
-
errors: $4.to_i,
|
|
423
|
-
duration: $5.to_f,
|
|
424
|
-
success: $3.to_i == 0 && $4.to_i == 0
|
|
425
|
-
}
|
|
426
|
-
else
|
|
427
|
-
# Fallback to counting dots/F/E/S
|
|
428
|
-
dots = output.scan(/[.FES]/).join
|
|
429
|
-
{
|
|
430
|
-
tests: dots.length,
|
|
431
|
-
failures: dots.count("F"),
|
|
432
|
-
errors: dots.count("E"),
|
|
433
|
-
skipped: dots.count("S"),
|
|
434
|
-
success: !dots.include?("F") && !dots.include?("E")
|
|
435
|
-
}
|
|
436
|
-
end
|
|
437
|
-
end
|
|
438
356
|
end
|
|
439
357
|
end
|
|
440
358
|
end
|