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.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/e2e-runner/config.yml +70 -0
  3. data/.ace-defaults/nav/protocols/guide-sources/ace-test-runner-e2e.yml +11 -0
  4. data/.ace-defaults/nav/protocols/skill-sources/ace-test-runner-e2e.yml +19 -0
  5. data/.ace-defaults/nav/protocols/tmpl-sources/ace-test-runner-e2e.yml +12 -0
  6. data/.ace-defaults/nav/protocols/wfi-sources/ace-test-runner-e2e.yml +11 -0
  7. data/CHANGELOG.md +1166 -0
  8. data/LICENSE +21 -0
  9. data/README.md +42 -0
  10. data/Rakefile +15 -0
  11. data/exe/ace-test-e2e +15 -0
  12. data/exe/ace-test-e2e-sh +67 -0
  13. data/exe/ace-test-e2e-suite +13 -0
  14. data/handbook/guides/e2e-testing.g.md +124 -0
  15. data/handbook/guides/scenario-yml-reference.g.md +182 -0
  16. data/handbook/guides/tc-authoring.g.md +131 -0
  17. data/handbook/skills/as-e2e-create/SKILL.md +30 -0
  18. data/handbook/skills/as-e2e-fix/SKILL.md +35 -0
  19. data/handbook/skills/as-e2e-manage/SKILL.md +31 -0
  20. data/handbook/skills/as-e2e-plan-changes/SKILL.md +30 -0
  21. data/handbook/skills/as-e2e-review/SKILL.md +35 -0
  22. data/handbook/skills/as-e2e-rewrite/SKILL.md +31 -0
  23. data/handbook/skills/as-e2e-run/SKILL.md +48 -0
  24. data/handbook/skills/as-e2e-setup-sandbox/SKILL.md +34 -0
  25. data/handbook/templates/ace-taskflow-fixture.template.md +322 -0
  26. data/handbook/templates/agent-experience-report.template.md +89 -0
  27. data/handbook/templates/metadata.template.yml +49 -0
  28. data/handbook/templates/scenario.yml.template.yml +60 -0
  29. data/handbook/templates/tc-file.template.md +45 -0
  30. data/handbook/templates/test-report.template.md +94 -0
  31. data/handbook/workflow-instructions/e2e/analyze-failures.wf.md +126 -0
  32. data/handbook/workflow-instructions/e2e/create.wf.md +395 -0
  33. data/handbook/workflow-instructions/e2e/execute.wf.md +253 -0
  34. data/handbook/workflow-instructions/e2e/fix.wf.md +166 -0
  35. data/handbook/workflow-instructions/e2e/manage.wf.md +179 -0
  36. data/handbook/workflow-instructions/e2e/plan-changes.wf.md +255 -0
  37. data/handbook/workflow-instructions/e2e/review.wf.md +286 -0
  38. data/handbook/workflow-instructions/e2e/rewrite.wf.md +281 -0
  39. data/handbook/workflow-instructions/e2e/run.wf.md +355 -0
  40. data/handbook/workflow-instructions/e2e/setup-sandbox.wf.md +461 -0
  41. data/lib/ace/test/end_to_end_runner/atoms/display_helpers.rb +234 -0
  42. data/lib/ace/test/end_to_end_runner/atoms/prompt_builder.rb +199 -0
  43. data/lib/ace/test/end_to_end_runner/atoms/result_parser.rb +166 -0
  44. data/lib/ace/test/end_to_end_runner/atoms/skill_prompt_builder.rb +166 -0
  45. data/lib/ace/test/end_to_end_runner/atoms/skill_result_parser.rb +244 -0
  46. data/lib/ace/test/end_to_end_runner/atoms/suite_report_prompt_builder.rb +103 -0
  47. data/lib/ace/test/end_to_end_runner/atoms/tc_fidelity_validator.rb +39 -0
  48. data/lib/ace/test/end_to_end_runner/atoms/test_case_parser.rb +108 -0
  49. data/lib/ace/test/end_to_end_runner/cli/commands/run_suite.rb +130 -0
  50. data/lib/ace/test/end_to_end_runner/cli/commands/run_test.rb +156 -0
  51. data/lib/ace/test/end_to_end_runner/models/test_case.rb +47 -0
  52. data/lib/ace/test/end_to_end_runner/models/test_result.rb +115 -0
  53. data/lib/ace/test/end_to_end_runner/models/test_scenario.rb +90 -0
  54. data/lib/ace/test/end_to_end_runner/molecules/affected_detector.rb +92 -0
  55. data/lib/ace/test/end_to_end_runner/molecules/config_loader.rb +75 -0
  56. data/lib/ace/test/end_to_end_runner/molecules/failure_finder.rb +203 -0
  57. data/lib/ace/test/end_to_end_runner/molecules/fixture_copier.rb +35 -0
  58. data/lib/ace/test/end_to_end_runner/molecules/pipeline_executor.rb +121 -0
  59. data/lib/ace/test/end_to_end_runner/molecules/pipeline_prompt_bundler.rb +182 -0
  60. data/lib/ace/test/end_to_end_runner/molecules/pipeline_report_generator.rb +321 -0
  61. data/lib/ace/test/end_to_end_runner/molecules/pipeline_sandbox_builder.rb +131 -0
  62. data/lib/ace/test/end_to_end_runner/molecules/progress_display_manager.rb +172 -0
  63. data/lib/ace/test/end_to_end_runner/molecules/report_writer.rb +259 -0
  64. data/lib/ace/test/end_to_end_runner/molecules/scenario_loader.rb +254 -0
  65. data/lib/ace/test/end_to_end_runner/molecules/setup_executor.rb +181 -0
  66. data/lib/ace/test/end_to_end_runner/molecules/simple_display_manager.rb +72 -0
  67. data/lib/ace/test/end_to_end_runner/molecules/suite_progress_display_manager.rb +223 -0
  68. data/lib/ace/test/end_to_end_runner/molecules/suite_report_writer.rb +277 -0
  69. data/lib/ace/test/end_to_end_runner/molecules/suite_simple_display_manager.rb +116 -0
  70. data/lib/ace/test/end_to_end_runner/molecules/test_discoverer.rb +136 -0
  71. data/lib/ace/test/end_to_end_runner/molecules/test_executor.rb +332 -0
  72. data/lib/ace/test/end_to_end_runner/organisms/suite_orchestrator.rb +830 -0
  73. data/lib/ace/test/end_to_end_runner/organisms/test_orchestrator.rb +442 -0
  74. data/lib/ace/test/end_to_end_runner/version.rb +9 -0
  75. data/lib/ace/test/end_to_end_runner.rb +71 -0
  76. 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