ace-test-runner-e2e 0.29.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/e2e-runner/config.yml +70 -0
- data/.ace-defaults/nav/protocols/guide-sources/ace-test-runner-e2e.yml +11 -0
- data/.ace-defaults/nav/protocols/skill-sources/ace-test-runner-e2e.yml +19 -0
- data/.ace-defaults/nav/protocols/tmpl-sources/ace-test-runner-e2e.yml +12 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-test-runner-e2e.yml +11 -0
- data/CHANGELOG.md +1166 -0
- data/LICENSE +21 -0
- data/README.md +42 -0
- data/Rakefile +15 -0
- data/exe/ace-test-e2e +15 -0
- data/exe/ace-test-e2e-sh +67 -0
- data/exe/ace-test-e2e-suite +13 -0
- data/handbook/guides/e2e-testing.g.md +124 -0
- data/handbook/guides/scenario-yml-reference.g.md +182 -0
- data/handbook/guides/tc-authoring.g.md +131 -0
- data/handbook/skills/as-e2e-create/SKILL.md +30 -0
- data/handbook/skills/as-e2e-fix/SKILL.md +35 -0
- data/handbook/skills/as-e2e-manage/SKILL.md +31 -0
- data/handbook/skills/as-e2e-plan-changes/SKILL.md +30 -0
- data/handbook/skills/as-e2e-review/SKILL.md +35 -0
- data/handbook/skills/as-e2e-rewrite/SKILL.md +31 -0
- data/handbook/skills/as-e2e-run/SKILL.md +48 -0
- data/handbook/skills/as-e2e-setup-sandbox/SKILL.md +34 -0
- data/handbook/templates/ace-taskflow-fixture.template.md +322 -0
- data/handbook/templates/agent-experience-report.template.md +89 -0
- data/handbook/templates/metadata.template.yml +49 -0
- data/handbook/templates/scenario.yml.template.yml +60 -0
- data/handbook/templates/tc-file.template.md +45 -0
- data/handbook/templates/test-report.template.md +94 -0
- data/handbook/workflow-instructions/e2e/analyze-failures.wf.md +126 -0
- data/handbook/workflow-instructions/e2e/create.wf.md +395 -0
- data/handbook/workflow-instructions/e2e/execute.wf.md +253 -0
- data/handbook/workflow-instructions/e2e/fix.wf.md +166 -0
- data/handbook/workflow-instructions/e2e/manage.wf.md +179 -0
- data/handbook/workflow-instructions/e2e/plan-changes.wf.md +255 -0
- data/handbook/workflow-instructions/e2e/review.wf.md +286 -0
- data/handbook/workflow-instructions/e2e/rewrite.wf.md +281 -0
- data/handbook/workflow-instructions/e2e/run.wf.md +355 -0
- data/handbook/workflow-instructions/e2e/setup-sandbox.wf.md +461 -0
- data/lib/ace/test/end_to_end_runner/atoms/display_helpers.rb +234 -0
- data/lib/ace/test/end_to_end_runner/atoms/prompt_builder.rb +199 -0
- data/lib/ace/test/end_to_end_runner/atoms/result_parser.rb +166 -0
- data/lib/ace/test/end_to_end_runner/atoms/skill_prompt_builder.rb +166 -0
- data/lib/ace/test/end_to_end_runner/atoms/skill_result_parser.rb +244 -0
- data/lib/ace/test/end_to_end_runner/atoms/suite_report_prompt_builder.rb +103 -0
- data/lib/ace/test/end_to_end_runner/atoms/tc_fidelity_validator.rb +39 -0
- data/lib/ace/test/end_to_end_runner/atoms/test_case_parser.rb +108 -0
- data/lib/ace/test/end_to_end_runner/cli/commands/run_suite.rb +130 -0
- data/lib/ace/test/end_to_end_runner/cli/commands/run_test.rb +156 -0
- data/lib/ace/test/end_to_end_runner/models/test_case.rb +47 -0
- data/lib/ace/test/end_to_end_runner/models/test_result.rb +115 -0
- data/lib/ace/test/end_to_end_runner/models/test_scenario.rb +90 -0
- data/lib/ace/test/end_to_end_runner/molecules/affected_detector.rb +92 -0
- data/lib/ace/test/end_to_end_runner/molecules/config_loader.rb +75 -0
- data/lib/ace/test/end_to_end_runner/molecules/failure_finder.rb +203 -0
- data/lib/ace/test/end_to_end_runner/molecules/fixture_copier.rb +35 -0
- data/lib/ace/test/end_to_end_runner/molecules/pipeline_executor.rb +121 -0
- data/lib/ace/test/end_to_end_runner/molecules/pipeline_prompt_bundler.rb +182 -0
- data/lib/ace/test/end_to_end_runner/molecules/pipeline_report_generator.rb +321 -0
- data/lib/ace/test/end_to_end_runner/molecules/pipeline_sandbox_builder.rb +131 -0
- data/lib/ace/test/end_to_end_runner/molecules/progress_display_manager.rb +172 -0
- data/lib/ace/test/end_to_end_runner/molecules/report_writer.rb +259 -0
- data/lib/ace/test/end_to_end_runner/molecules/scenario_loader.rb +254 -0
- data/lib/ace/test/end_to_end_runner/molecules/setup_executor.rb +181 -0
- data/lib/ace/test/end_to_end_runner/molecules/simple_display_manager.rb +72 -0
- data/lib/ace/test/end_to_end_runner/molecules/suite_progress_display_manager.rb +223 -0
- data/lib/ace/test/end_to_end_runner/molecules/suite_report_writer.rb +277 -0
- data/lib/ace/test/end_to_end_runner/molecules/suite_simple_display_manager.rb +116 -0
- data/lib/ace/test/end_to_end_runner/molecules/test_discoverer.rb +136 -0
- data/lib/ace/test/end_to_end_runner/molecules/test_executor.rb +332 -0
- data/lib/ace/test/end_to_end_runner/organisms/suite_orchestrator.rb +830 -0
- data/lib/ace/test/end_to_end_runner/organisms/test_orchestrator.rb +442 -0
- data/lib/ace/test/end_to_end_runner/version.rb +9 -0
- data/lib/ace/test/end_to_end_runner.rb +71 -0
- metadata +220 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Test
|
|
5
|
+
module EndToEndRunner
|
|
6
|
+
module Molecules
|
|
7
|
+
# Line-by-line display manager for E2E test output (default mode).
|
|
8
|
+
# Optimized for piping, log capture, and agent consumption.
|
|
9
|
+
# No ANSI cursor control — each event appends a new line.
|
|
10
|
+
class SimpleDisplayManager
|
|
11
|
+
# @param scenarios [Array<Models::TestScenario>] tests to run
|
|
12
|
+
# @param output [IO] output stream
|
|
13
|
+
# @param parallel [Integer] parallelism level
|
|
14
|
+
def initialize(scenarios, output:, parallel:)
|
|
15
|
+
@scenarios = scenarios
|
|
16
|
+
@output = output
|
|
17
|
+
@parallel = parallel
|
|
18
|
+
@use_color = output.respond_to?(:tty?) && output.tty?
|
|
19
|
+
@start_time = Time.now
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Print header showing test count and parallelism
|
|
23
|
+
def initialize_display
|
|
24
|
+
package = @scenarios.first&.package || "unknown"
|
|
25
|
+
@output.puts "Discovered #{@scenarios.size} E2E tests in #{package}"
|
|
26
|
+
@output.puts "Running with parallelism: #{@parallel}" if @parallel > 1
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Print line when a test begins
|
|
30
|
+
# @param scenario [Models::TestScenario]
|
|
31
|
+
def test_started(scenario)
|
|
32
|
+
@output.puts "[started] #{scenario.test_id}: #{scenario.title}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Print line when a test completes
|
|
36
|
+
# @param scenario [Models::TestScenario]
|
|
37
|
+
# @param result [Models::TestResult]
|
|
38
|
+
# @param completed [Integer] number completed so far
|
|
39
|
+
# @param total [Integer] total number of tests
|
|
40
|
+
def test_completed(scenario, result, completed, total)
|
|
41
|
+
h = Atoms::DisplayHelpers
|
|
42
|
+
icon = h.color(h.status_icon(result.success?), result.success? ? :green : :red, use_color: @use_color)
|
|
43
|
+
elapsed = h.format_elapsed(result.duration)
|
|
44
|
+
tc = h.tc_count_display(result)
|
|
45
|
+
|
|
46
|
+
@output.puts "[#{completed}/#{total}] #{icon} #{elapsed} #{scenario.test_id} #{result.status.upcase}#{tc}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Print a single-test result line (for run-single-test mode)
|
|
50
|
+
# @param result [Models::TestResult]
|
|
51
|
+
def show_single_result(result)
|
|
52
|
+
@output.puts Atoms::DisplayHelpers.format_single_result(result, use_color: @use_color)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# No-op — simple mode doesn't need refresh
|
|
56
|
+
def refresh
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Print structured summary block
|
|
60
|
+
# @param results [Array<Models::TestResult>]
|
|
61
|
+
# @param report_path [String]
|
|
62
|
+
def show_summary(results, report_path)
|
|
63
|
+
lines = Atoms::DisplayHelpers.format_summary_lines(
|
|
64
|
+
results, Time.now - @start_time, report_path, use_color: @use_color
|
|
65
|
+
)
|
|
66
|
+
lines.each { |line| @output.puts line }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Test
|
|
5
|
+
module EndToEndRunner
|
|
6
|
+
module Molecules
|
|
7
|
+
# Animated ANSI table display manager for suite-level E2E test output (--progress mode).
|
|
8
|
+
# Updates test rows in place using cursor movement escape codes.
|
|
9
|
+
# Modeled on ProgressDisplayManager for visual consistency.
|
|
10
|
+
class SuiteProgressDisplayManager
|
|
11
|
+
# @param test_queue [Array<Hash>] flat list of {package:, test_file:} items
|
|
12
|
+
# @param output [IO] output stream
|
|
13
|
+
# @param use_color [Boolean] enable ANSI color
|
|
14
|
+
# @param pkg_width [Integer] column width for package names
|
|
15
|
+
# @param name_width [Integer] column width for test names
|
|
16
|
+
def initialize(test_queue, output:, use_color:, pkg_width:, name_width:)
|
|
17
|
+
@test_queue = test_queue
|
|
18
|
+
@output = output
|
|
19
|
+
@use_color = use_color
|
|
20
|
+
@pkg_width = pkg_width
|
|
21
|
+
@name_width = name_width
|
|
22
|
+
@start_time = Time.now
|
|
23
|
+
@last_refresh = Time.at(0)
|
|
24
|
+
|
|
25
|
+
# Build row map: "package:test_file" => line number
|
|
26
|
+
@rows = {}
|
|
27
|
+
@states = {} # key => :waiting | :running | :completed
|
|
28
|
+
@results = {} # key => result hash
|
|
29
|
+
@started_at = {} # key => Time
|
|
30
|
+
|
|
31
|
+
@test_queue.each_with_index do |item, index|
|
|
32
|
+
key = row_key(item[:package], item[:test_file])
|
|
33
|
+
@rows[key] = index + 5 # account for header lines (sep + title + sep + blank)
|
|
34
|
+
@states[key] = :waiting
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Clear screen, print header, pre-render all rows as "waiting", print footer
|
|
39
|
+
# @param total_tests [Integer]
|
|
40
|
+
# @param pkg_count [Integer]
|
|
41
|
+
def show_header(total_tests, pkg_count)
|
|
42
|
+
dh = Atoms::DisplayHelpers
|
|
43
|
+
|
|
44
|
+
# Clear screen (preserves scrollback)
|
|
45
|
+
@output.print "\033[H\033[J"
|
|
46
|
+
|
|
47
|
+
@output.puts dh.double_separator
|
|
48
|
+
@output.puts " ACE E2E Test Suite - Running #{total_tests} tests across #{pkg_count} packages"
|
|
49
|
+
@output.puts dh.double_separator
|
|
50
|
+
@output.puts
|
|
51
|
+
|
|
52
|
+
# Pre-render all rows in waiting state
|
|
53
|
+
@test_queue.each do |item|
|
|
54
|
+
print_row(item[:package], item[:test_file])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
@output.puts
|
|
58
|
+
@output.puts
|
|
59
|
+
# Guard against empty queue: default to current line if no rows
|
|
60
|
+
@footer_line = @rows.values.max + 3 if @rows.values.any?
|
|
61
|
+
update_footer
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Update row to "running" state
|
|
65
|
+
# @param package [String]
|
|
66
|
+
# @param test_file [String]
|
|
67
|
+
def test_started(package, test_file)
|
|
68
|
+
key = row_key(package, test_file)
|
|
69
|
+
@states[key] = :running
|
|
70
|
+
@started_at[key] = Time.now
|
|
71
|
+
print_row(package, test_file)
|
|
72
|
+
update_footer
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Update row to completed state with result
|
|
76
|
+
# @param result [Hash] with :status, :passed_cases, :total_cases
|
|
77
|
+
# @param package [String]
|
|
78
|
+
# @param test_file [String]
|
|
79
|
+
# @param elapsed [Numeric] seconds
|
|
80
|
+
def test_completed(result, package, test_file, elapsed)
|
|
81
|
+
key = row_key(package, test_file)
|
|
82
|
+
@states[key] = :completed
|
|
83
|
+
@results[key] = result.merge(elapsed: elapsed)
|
|
84
|
+
print_row(package, test_file)
|
|
85
|
+
update_footer
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Refresh running rows to update elapsed timers + footer
|
|
89
|
+
# Throttled to ~4Hz — the poll loop runs at 10Hz but redraws are expensive
|
|
90
|
+
def refresh
|
|
91
|
+
now = Time.now
|
|
92
|
+
return if now - @last_refresh < REFRESH_INTERVAL
|
|
93
|
+
|
|
94
|
+
@last_refresh = now
|
|
95
|
+
|
|
96
|
+
@states.each do |key, state|
|
|
97
|
+
next unless state == :running
|
|
98
|
+
|
|
99
|
+
item = find_item(key)
|
|
100
|
+
print_row(item[:package], item[:test_file]) if item
|
|
101
|
+
end
|
|
102
|
+
update_footer
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Move past the table area and print summary
|
|
106
|
+
# @param results [Hash] with :total, :passed, :failed, :errors, :packages
|
|
107
|
+
# @param duration [Numeric] total elapsed seconds
|
|
108
|
+
def show_summary(results, duration)
|
|
109
|
+
move_to_line((@footer_line || 6) + 1)
|
|
110
|
+
@output.puts
|
|
111
|
+
|
|
112
|
+
failed_details = collect_failed_details(results)
|
|
113
|
+
|
|
114
|
+
lines = Atoms::DisplayHelpers.format_suite_summary(
|
|
115
|
+
{
|
|
116
|
+
total: results[:total],
|
|
117
|
+
passed: results[:passed],
|
|
118
|
+
failed: results[:failed],
|
|
119
|
+
errors: results[:errors],
|
|
120
|
+
total_cases: results[:total_cases] || 0,
|
|
121
|
+
passed_cases: results[:passed_cases] || 0,
|
|
122
|
+
duration: duration,
|
|
123
|
+
failed_details: failed_details
|
|
124
|
+
},
|
|
125
|
+
use_color: @use_color
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
lines.each { |line| @output.puts line }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def row_key(package, test_file)
|
|
134
|
+
"#{package}:#{test_file}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def find_item(key)
|
|
138
|
+
@test_queue.find { |item| row_key(item[:package], item[:test_file]) == key }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def extract_test_name(test_file)
|
|
142
|
+
File.basename(File.dirname(test_file))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def print_row(package, test_file)
|
|
146
|
+
dh = Atoms::DisplayHelpers
|
|
147
|
+
key = row_key(package, test_file)
|
|
148
|
+
line = @rows[key]
|
|
149
|
+
state = @states[key]
|
|
150
|
+
test_name = extract_test_name(test_file)
|
|
151
|
+
|
|
152
|
+
move_to_line(line)
|
|
153
|
+
@output.print "\033[K" # clear line
|
|
154
|
+
|
|
155
|
+
pkg_col = package.ljust(@pkg_width)
|
|
156
|
+
name_col = test_name.ljust(@name_width)
|
|
157
|
+
|
|
158
|
+
case state
|
|
159
|
+
when :waiting
|
|
160
|
+
icon = dh.color(dh.waiting_icon, :gray, use_color: @use_color)
|
|
161
|
+
elapsed = " -"
|
|
162
|
+
@output.print "#{icon} #{elapsed} #{pkg_col} #{name_col} waiting"
|
|
163
|
+
|
|
164
|
+
when :running
|
|
165
|
+
icon = dh.color(dh.running_icon, :cyan, use_color: @use_color)
|
|
166
|
+
secs = Time.now - (@started_at[key] || Time.now)
|
|
167
|
+
elapsed = dh.format_suite_elapsed(secs)
|
|
168
|
+
@output.print "#{icon} #{elapsed} #{pkg_col} #{name_col} running"
|
|
169
|
+
|
|
170
|
+
when :completed
|
|
171
|
+
result = @results[key]
|
|
172
|
+
success = result[:status] == "pass"
|
|
173
|
+
icon = dh.color(dh.status_icon(success), success ? :green : :red, use_color: @use_color)
|
|
174
|
+
elapsed = dh.format_suite_elapsed(result[:elapsed])
|
|
175
|
+
|
|
176
|
+
cases_str = ""
|
|
177
|
+
if result[:total_cases] && result[:total_cases] > 0
|
|
178
|
+
cases_str = "#{result[:passed_cases]}/#{result[:total_cases]} cases"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
@output.print "#{icon} #{elapsed} #{pkg_col} #{name_col} #{cases_str}"
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def update_footer
|
|
186
|
+
return unless @footer_line
|
|
187
|
+
|
|
188
|
+
move_to_line(@footer_line)
|
|
189
|
+
@output.print "\033[K"
|
|
190
|
+
|
|
191
|
+
active = @states.count { |_, s| s == :running }
|
|
192
|
+
completed = @states.count { |_, s| s == :completed }
|
|
193
|
+
waiting = @states.count { |_, s| s == :waiting }
|
|
194
|
+
|
|
195
|
+
@output.print "Active: #{active} | Completed: #{completed} | Waiting: #{waiting}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def move_to_line(line)
|
|
199
|
+
@output.print "\033[#{line};1H"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def collect_failed_details(results)
|
|
203
|
+
failed_details = []
|
|
204
|
+
results[:packages].each do |package, test_results|
|
|
205
|
+
test_results.each do |result|
|
|
206
|
+
next if result[:status] == "pass"
|
|
207
|
+
|
|
208
|
+
test_name = result[:test_name] || "unknown"
|
|
209
|
+
cases = if result[:total_cases] && result[:total_cases] > 0
|
|
210
|
+
"#{result[:passed_cases]}/#{result[:total_cases]} cases"
|
|
211
|
+
else
|
|
212
|
+
result[:error] || result[:summary] || "failed"
|
|
213
|
+
end
|
|
214
|
+
failed_details << {package: package, test_name: test_name, cases: cases}
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
failed_details
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "ace/llm"
|
|
6
|
+
require "ace/llm/query_interface"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Test
|
|
10
|
+
module EndToEndRunner
|
|
11
|
+
module Molecules
|
|
12
|
+
# Writes a suite-level final report aggregating all test results
|
|
13
|
+
#
|
|
14
|
+
# Uses LLM synthesis to generate rich reports with root cause analysis,
|
|
15
|
+
# friction insights, and improvement suggestions. Falls back to a static
|
|
16
|
+
# template on LLM failure.
|
|
17
|
+
class SuiteReportWriter
|
|
18
|
+
# @param config [Hash, nil] Configuration hash (reads reporting.model and reporting.timeout)
|
|
19
|
+
def initialize(config: nil)
|
|
20
|
+
reporting = (config || {}).dig("reporting") || {}
|
|
21
|
+
@model = reporting["model"] || "glite"
|
|
22
|
+
@timeout = reporting["timeout"] || 60
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Write a suite-level final report
|
|
26
|
+
#
|
|
27
|
+
# @param results [Array<Models::TestResult>] Test results (ordered)
|
|
28
|
+
# @param scenarios [Array<Models::TestScenario>] Corresponding scenarios
|
|
29
|
+
# @param package [String] Package name (e.g., "ace-lint")
|
|
30
|
+
# @param timestamp [String] Timestamp ID for this run
|
|
31
|
+
# @param base_dir [String] Base directory for cache output
|
|
32
|
+
# @return [String] Path to the written report file
|
|
33
|
+
def write(results, scenarios, package:, timestamp:, base_dir:)
|
|
34
|
+
cache_dir = File.join(base_dir, ".ace-local", "test-e2e")
|
|
35
|
+
FileUtils.mkdir_p(cache_dir)
|
|
36
|
+
|
|
37
|
+
report_path = File.join(cache_dir, "#{timestamp}-final-report.md")
|
|
38
|
+
|
|
39
|
+
overall_status = compute_status(results)
|
|
40
|
+
executed_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
41
|
+
|
|
42
|
+
content = synthesize_report(
|
|
43
|
+
results, scenarios,
|
|
44
|
+
package: package,
|
|
45
|
+
timestamp: timestamp,
|
|
46
|
+
overall_status: overall_status,
|
|
47
|
+
executed_at: executed_at
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
File.write(report_path, content)
|
|
51
|
+
report_path
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Attempt LLM synthesis, falling back to static template
|
|
57
|
+
def synthesize_report(results, scenarios, package:, timestamp:, overall_status:, executed_at:)
|
|
58
|
+
results_data = build_results_data(results, scenarios)
|
|
59
|
+
|
|
60
|
+
prompt_builder = Atoms::SuiteReportPromptBuilder.new
|
|
61
|
+
user_prompt = prompt_builder.build(
|
|
62
|
+
results_data,
|
|
63
|
+
package: package,
|
|
64
|
+
timestamp: timestamp,
|
|
65
|
+
overall_status: overall_status,
|
|
66
|
+
executed_at: executed_at
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
response = Ace::LLM::QueryInterface.query(
|
|
70
|
+
@model,
|
|
71
|
+
user_prompt,
|
|
72
|
+
system: Atoms::SuiteReportPromptBuilder::SYSTEM_PROMPT,
|
|
73
|
+
timeout: @timeout,
|
|
74
|
+
temperature: 0.3
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
total_passed = results.sum(&:passed_count)
|
|
78
|
+
total_tc = results.sum(&:total_count)
|
|
79
|
+
validate_overall_line(response[:text], total_passed, total_tc)
|
|
80
|
+
rescue => e
|
|
81
|
+
# LLM failed — fall back to static report
|
|
82
|
+
warn "Warning: LLM synthesis failed (#{e.class}: #{e.message}), using static report" if ENV["DEBUG"]
|
|
83
|
+
executed_date = Time.now.utc.strftime("%Y-%m-%d")
|
|
84
|
+
total_passed = results.sum(&:passed_count)
|
|
85
|
+
total_failed = results.sum(&:failed_count)
|
|
86
|
+
total_tc = results.sum(&:total_count)
|
|
87
|
+
|
|
88
|
+
build_static_report(
|
|
89
|
+
results, scenarios,
|
|
90
|
+
package: package,
|
|
91
|
+
timestamp: timestamp,
|
|
92
|
+
overall_status: overall_status,
|
|
93
|
+
executed_at: executed_at,
|
|
94
|
+
executed_date: executed_date,
|
|
95
|
+
total_passed: total_passed,
|
|
96
|
+
total_failed: total_failed,
|
|
97
|
+
total_tc: total_tc
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Read summary and experience report content from each result's report dir
|
|
102
|
+
def build_results_data(results, scenarios)
|
|
103
|
+
results.each_with_index.map do |result, i|
|
|
104
|
+
scenario = scenarios[i]
|
|
105
|
+
report_dir = result.report_dir
|
|
106
|
+
|
|
107
|
+
summary_content = read_report_file(report_dir, "summary.r.md")
|
|
108
|
+
experience_content = read_report_file(report_dir, "experience.r.md")
|
|
109
|
+
|
|
110
|
+
{
|
|
111
|
+
test_id: result.test_id,
|
|
112
|
+
title: scenario.title,
|
|
113
|
+
status: result.status,
|
|
114
|
+
passed: result.passed_count,
|
|
115
|
+
failed: result.failed_count,
|
|
116
|
+
total: result.total_count,
|
|
117
|
+
test_cases: result.test_cases,
|
|
118
|
+
report_dir_name: report_dir ? File.basename(report_dir) : nil,
|
|
119
|
+
summary_content: summary_content,
|
|
120
|
+
experience_content: experience_content
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Safely read a report file, returning nil if missing
|
|
126
|
+
def read_report_file(report_dir, filename)
|
|
127
|
+
return nil unless report_dir
|
|
128
|
+
|
|
129
|
+
path = File.join(report_dir, filename)
|
|
130
|
+
return nil unless File.exist?(path)
|
|
131
|
+
|
|
132
|
+
File.read(path)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Validate the LLM-generated Overall line against deterministic totals.
|
|
136
|
+
# If the LLM hallucinated wrong numbers, replace the line with correct values.
|
|
137
|
+
def validate_overall_line(report_text, expected_passed, expected_total)
|
|
138
|
+
expected_pct = (expected_total > 0) ? (expected_passed * 100.0 / expected_total).round(0) : 0
|
|
139
|
+
correct_line = "**Overall:** #{expected_passed}/#{expected_total} test cases passed (#{expected_pct}%)"
|
|
140
|
+
|
|
141
|
+
# Match patterns like "**Overall:** X/Y test cases passed (Z%)"
|
|
142
|
+
overall_pattern = /\*\*Overall:\*\*\s*\d+\/\d+\s+test cases passed\s*\(\d+%\)/
|
|
143
|
+
|
|
144
|
+
if report_text.match?(overall_pattern)
|
|
145
|
+
report_text.gsub(overall_pattern, correct_line)
|
|
146
|
+
else
|
|
147
|
+
# No Overall line found — append the correct one after the summary table
|
|
148
|
+
"#{report_text.rstrip}\n\n#{correct_line}\n"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def compute_status(results)
|
|
153
|
+
# Filter out skipped tests for status computation
|
|
154
|
+
executed = results.reject(&:skipped?)
|
|
155
|
+
return "skip" if executed.empty?
|
|
156
|
+
|
|
157
|
+
if executed.all?(&:success?)
|
|
158
|
+
"pass"
|
|
159
|
+
elsif executed.any?(&:success?)
|
|
160
|
+
"partial"
|
|
161
|
+
else
|
|
162
|
+
"fail"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Static fallback report (original template-based approach)
|
|
167
|
+
def build_static_report(results, scenarios, package:, timestamp:, overall_status:,
|
|
168
|
+
executed_at:, executed_date:, total_passed:, total_failed:, total_tc:)
|
|
169
|
+
total_skipped = results.count(&:skipped?)
|
|
170
|
+
|
|
171
|
+
parts = []
|
|
172
|
+
parts << build_frontmatter(
|
|
173
|
+
timestamp: timestamp, package: package, overall_status: overall_status,
|
|
174
|
+
tests_run: results.size, executed_at: executed_at, skipped: total_skipped
|
|
175
|
+
)
|
|
176
|
+
parts << build_header(package: package, tests_run: results.size, executed_date: executed_date, skipped: total_skipped)
|
|
177
|
+
parts << build_summary_table(results, scenarios)
|
|
178
|
+
parts << build_overall_line(total_passed: total_passed, total_tc: total_tc)
|
|
179
|
+
parts << build_failed_section(results, scenarios) if results.any?(&:failed?)
|
|
180
|
+
parts << build_reports_section(results, scenarios)
|
|
181
|
+
parts.join("\n")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def build_frontmatter(timestamp:, package:, overall_status:, tests_run:, executed_at:, skipped: 0)
|
|
185
|
+
skipped_line = (skipped > 0) ? "\nskipped: #{skipped}" : ""
|
|
186
|
+
<<~FRONTMATTER
|
|
187
|
+
---
|
|
188
|
+
suite-id: #{timestamp}
|
|
189
|
+
package: #{package}
|
|
190
|
+
status: #{overall_status}
|
|
191
|
+
tests-run: #{tests_run}#{skipped_line}
|
|
192
|
+
executed: #{executed_at}
|
|
193
|
+
---
|
|
194
|
+
FRONTMATTER
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def build_header(package:, tests_run:, executed_date:, skipped: 0)
|
|
198
|
+
skipped_info = (skipped > 0) ? " (#{skipped} skipped)" : ""
|
|
199
|
+
<<~HEADER
|
|
200
|
+
# E2E Test Suite Report
|
|
201
|
+
|
|
202
|
+
**Package:** #{package}
|
|
203
|
+
**Tests:** #{tests_run}#{skipped_info}
|
|
204
|
+
**Executed:** #{executed_date}
|
|
205
|
+
HEADER
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def build_summary_table(results, scenarios)
|
|
209
|
+
rows = results.each_with_index.map do |result, i|
|
|
210
|
+
scenario = scenarios[i]
|
|
211
|
+
status_label = result.status.capitalize
|
|
212
|
+
passed = result.skipped? ? "-" : result.passed_count.to_s
|
|
213
|
+
failed = result.skipped? ? "-" : result.failed_count.to_s
|
|
214
|
+
total = result.skipped? ? "-" : result.total_count.to_s
|
|
215
|
+
"| #{result.test_id} | #{scenario.title} | #{status_label} | #{passed} | #{failed} | #{total} |"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
<<~TABLE
|
|
219
|
+
## Summary
|
|
220
|
+
|
|
221
|
+
| Test ID | Title | Status | Passed | Failed | Total |
|
|
222
|
+
|---------|-------|--------|--------|--------|-------|
|
|
223
|
+
#{rows.join("\n")}
|
|
224
|
+
TABLE
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def build_overall_line(total_passed:, total_tc:)
|
|
228
|
+
pct = (total_tc > 0) ? (total_passed * 100.0 / total_tc).round(0) : 0
|
|
229
|
+
"**Overall:** #{total_passed}/#{total_tc} test cases passed (#{pct}%)\n"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def build_failed_section(results, scenarios)
|
|
233
|
+
parts = ["\n## Failed Tests\n"]
|
|
234
|
+
|
|
235
|
+
results.each_with_index do |result, i|
|
|
236
|
+
next if result.success? || result.skipped?
|
|
237
|
+
|
|
238
|
+
scenario = scenarios[i]
|
|
239
|
+
parts << "### #{result.test_id}: #{scenario.title} (#{result.passed_count}/#{result.total_count})\n"
|
|
240
|
+
|
|
241
|
+
failed_tcs = result.test_cases.select { |tc| tc[:status] == "fail" }
|
|
242
|
+
if failed_tcs.any?
|
|
243
|
+
parts << "**Failed Test Cases:**"
|
|
244
|
+
failed_tcs.each do |tc|
|
|
245
|
+
parts << "- #{tc[:id]}: #{tc[:description]}"
|
|
246
|
+
end
|
|
247
|
+
parts << ""
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
if result.report_dir
|
|
251
|
+
parts << "**Report:** #{result.report_dir}\n"
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
parts.join("\n")
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def build_reports_section(results, scenarios)
|
|
259
|
+
rows = results.each_with_index.map do |result, i|
|
|
260
|
+
dir = result.report_dir ? File.basename(result.report_dir) : "N/A"
|
|
261
|
+
"| #{result.test_id} | #{dir} |"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
<<~SECTION
|
|
265
|
+
|
|
266
|
+
## Reports
|
|
267
|
+
|
|
268
|
+
| Test ID | Reports Folder |
|
|
269
|
+
|---------|----------------|
|
|
270
|
+
#{rows.join("\n")}
|
|
271
|
+
SECTION
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Test
|
|
5
|
+
module EndToEndRunner
|
|
6
|
+
module Molecules
|
|
7
|
+
# Line-by-line display manager for suite-level E2E test output (default mode).
|
|
8
|
+
# Extracted from SuiteOrchestrator to match the display manager pattern
|
|
9
|
+
# used by TestOrchestrator (SimpleDisplayManager / ProgressDisplayManager).
|
|
10
|
+
class SuiteSimpleDisplayManager
|
|
11
|
+
# @param test_queue [Array<Hash>] flat list of {package:, test_file:} items
|
|
12
|
+
# @param output [IO] output stream
|
|
13
|
+
# @param use_color [Boolean] enable ANSI color
|
|
14
|
+
# @param pkg_width [Integer] column width for package names
|
|
15
|
+
# @param name_width [Integer] column width for test names
|
|
16
|
+
def initialize(test_queue, output:, use_color:, pkg_width:, name_width:)
|
|
17
|
+
@test_queue = test_queue
|
|
18
|
+
@output = output
|
|
19
|
+
@use_color = use_color
|
|
20
|
+
@pkg_width = pkg_width
|
|
21
|
+
@name_width = name_width
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Print suite header with separator, title, separator
|
|
25
|
+
# @param total_tests [Integer]
|
|
26
|
+
# @param pkg_count [Integer]
|
|
27
|
+
def show_header(total_tests, pkg_count)
|
|
28
|
+
dh = Atoms::DisplayHelpers
|
|
29
|
+
@output.puts dh.double_separator
|
|
30
|
+
@output.puts " ACE E2E Test Suite - Running #{total_tests} tests across #{pkg_count} packages"
|
|
31
|
+
@output.puts dh.double_separator
|
|
32
|
+
@output.puts
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# No-op — simple mode doesn't show start events
|
|
36
|
+
def test_started(_package, _test_file)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Print a columnar result line
|
|
40
|
+
# @param result [Hash] with :status, :passed_cases, :total_cases
|
|
41
|
+
# @param package [String]
|
|
42
|
+
# @param test_file [String]
|
|
43
|
+
# @param elapsed [Numeric] seconds
|
|
44
|
+
def test_completed(result, package, test_file, elapsed)
|
|
45
|
+
dh = Atoms::DisplayHelpers
|
|
46
|
+
success = result[:status] == "pass"
|
|
47
|
+
icon = dh.color(dh.status_icon(success), success ? :green : :red, use_color: @use_color)
|
|
48
|
+
test_name = extract_test_name(test_file)
|
|
49
|
+
|
|
50
|
+
cases_str = ""
|
|
51
|
+
if result[:total_cases] && result[:total_cases] > 0
|
|
52
|
+
cases_str = "#{result[:passed_cases]}/#{result[:total_cases]} cases"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
line = dh.format_suite_test_line(
|
|
56
|
+
icon, elapsed, package, test_name, cases_str,
|
|
57
|
+
pkg_width: @pkg_width, name_width: @name_width
|
|
58
|
+
)
|
|
59
|
+
@output.puts line
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# No-op — simple mode doesn't need refresh
|
|
63
|
+
def refresh
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Print structured summary block
|
|
67
|
+
# @param results [Hash] with :total, :passed, :failed, :errors, :packages
|
|
68
|
+
# @param duration [Numeric] total elapsed seconds
|
|
69
|
+
def show_summary(results, duration)
|
|
70
|
+
failed_details = collect_failed_details(results)
|
|
71
|
+
|
|
72
|
+
lines = Atoms::DisplayHelpers.format_suite_summary(
|
|
73
|
+
{
|
|
74
|
+
total: results[:total],
|
|
75
|
+
passed: results[:passed],
|
|
76
|
+
failed: results[:failed],
|
|
77
|
+
errors: results[:errors],
|
|
78
|
+
total_cases: results[:total_cases] || 0,
|
|
79
|
+
passed_cases: results[:passed_cases] || 0,
|
|
80
|
+
duration: duration,
|
|
81
|
+
failed_details: failed_details
|
|
82
|
+
},
|
|
83
|
+
use_color: @use_color
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
lines.each { |line| @output.puts line }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def extract_test_name(test_file)
|
|
92
|
+
File.basename(File.dirname(test_file))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def collect_failed_details(results)
|
|
96
|
+
failed_details = []
|
|
97
|
+
results[:packages].each do |package, test_results|
|
|
98
|
+
test_results.each do |result|
|
|
99
|
+
next if result[:status] == "pass"
|
|
100
|
+
|
|
101
|
+
test_name = result[:test_name] || "unknown"
|
|
102
|
+
cases = if result[:total_cases] && result[:total_cases] > 0
|
|
103
|
+
"#{result[:passed_cases]}/#{result[:total_cases]} cases"
|
|
104
|
+
else
|
|
105
|
+
result[:error] || result[:summary] || "failed"
|
|
106
|
+
end
|
|
107
|
+
failed_details << {package: package, test_name: test_name, cases: cases}
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
failed_details
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|