ace-test-runner 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/test/runner.yml +35 -0
  3. data/.ace-defaults/test/suite.yml +31 -0
  4. data/.ace-defaults/test-runner/config.yml +61 -0
  5. data/CHANGELOG.md +626 -0
  6. data/LICENSE +21 -0
  7. data/README.md +42 -0
  8. data/Rakefile +14 -0
  9. data/exe/ace-test +26 -0
  10. data/exe/ace-test-suite +149 -0
  11. data/lib/ace/test_runner/atoms/command_builder.rb +165 -0
  12. data/lib/ace/test_runner/atoms/lazy_loader.rb +62 -0
  13. data/lib/ace/test_runner/atoms/line_number_resolver.rb +86 -0
  14. data/lib/ace/test_runner/atoms/report_directory_resolver.rb +48 -0
  15. data/lib/ace/test_runner/atoms/report_path_resolver.rb +67 -0
  16. data/lib/ace/test_runner/atoms/result_parser.rb +254 -0
  17. data/lib/ace/test_runner/atoms/test_detector.rb +114 -0
  18. data/lib/ace/test_runner/atoms/test_folder_detector.rb +53 -0
  19. data/lib/ace/test_runner/atoms/test_type_detector.rb +83 -0
  20. data/lib/ace/test_runner/atoms/timestamp_generator.rb +103 -0
  21. data/lib/ace/test_runner/cli/commands/test.rb +326 -0
  22. data/lib/ace/test_runner/cli.rb +16 -0
  23. data/lib/ace/test_runner/formatters/base_formatter.rb +102 -0
  24. data/lib/ace/test_runner/formatters/json_formatter.rb +90 -0
  25. data/lib/ace/test_runner/formatters/markdown_formatter.rb +91 -0
  26. data/lib/ace/test_runner/formatters/progress_file_formatter.rb +164 -0
  27. data/lib/ace/test_runner/formatters/progress_formatter.rb +328 -0
  28. data/lib/ace/test_runner/models/test_configuration.rb +165 -0
  29. data/lib/ace/test_runner/models/test_failure.rb +95 -0
  30. data/lib/ace/test_runner/models/test_group.rb +105 -0
  31. data/lib/ace/test_runner/models/test_report.rb +145 -0
  32. data/lib/ace/test_runner/models/test_result.rb +86 -0
  33. data/lib/ace/test_runner/molecules/cli_argument_parser.rb +263 -0
  34. data/lib/ace/test_runner/molecules/config_loader.rb +162 -0
  35. data/lib/ace/test_runner/molecules/deprecation_fixer.rb +204 -0
  36. data/lib/ace/test_runner/molecules/failed_package_reporter.rb +100 -0
  37. data/lib/ace/test_runner/molecules/failure_analyzer.rb +249 -0
  38. data/lib/ace/test_runner/molecules/in_process_runner.rb +249 -0
  39. data/lib/ace/test_runner/molecules/package_resolver.rb +106 -0
  40. data/lib/ace/test_runner/molecules/pattern_resolver.rb +146 -0
  41. data/lib/ace/test_runner/molecules/rake_integration.rb +218 -0
  42. data/lib/ace/test_runner/molecules/report_storage.rb +303 -0
  43. data/lib/ace/test_runner/molecules/smart_test_executor.rb +107 -0
  44. data/lib/ace/test_runner/molecules/test_executor.rb +162 -0
  45. data/lib/ace/test_runner/organisms/agent_reporter.rb +384 -0
  46. data/lib/ace/test_runner/organisms/report_generator.rb +151 -0
  47. data/lib/ace/test_runner/organisms/sequential_group_executor.rb +185 -0
  48. data/lib/ace/test_runner/organisms/test_orchestrator.rb +648 -0
  49. data/lib/ace/test_runner/rake_task.rb +90 -0
  50. data/lib/ace/test_runner/suite/display_helpers.rb +117 -0
  51. data/lib/ace/test_runner/suite/display_manager.rb +204 -0
  52. data/lib/ace/test_runner/suite/duration_estimator.rb +50 -0
  53. data/lib/ace/test_runner/suite/orchestrator.rb +120 -0
  54. data/lib/ace/test_runner/suite/process_monitor.rb +268 -0
  55. data/lib/ace/test_runner/suite/result_aggregator.rb +176 -0
  56. data/lib/ace/test_runner/suite/simple_display_manager.rb +122 -0
  57. data/lib/ace/test_runner/suite.rb +22 -0
  58. data/lib/ace/test_runner/version.rb +7 -0
  59. data/lib/ace/test_runner.rb +69 -0
  60. metadata +246 -0
@@ -0,0 +1,648 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../formatters/base_formatter"
4
+ require_relative "../molecules/config_loader"
5
+ require_relative "../molecules/pattern_resolver"
6
+ require_relative "../atoms/report_directory_resolver"
7
+ require_relative "sequential_group_executor"
8
+
9
+ module Ace
10
+ module TestRunner
11
+ module Organisms
12
+ # Main orchestrator that coordinates the entire test execution flow
13
+ class TestOrchestrator
14
+ attr_reader :configuration, :result
15
+
16
+ def initialize(options = {})
17
+ @package_dir = options[:package_dir]
18
+ @original_dir = Dir.pwd
19
+ @options = options
20
+
21
+ # Fail fast: validate package_dir exists before proceeding
22
+ if @package_dir && !Dir.exist?(@package_dir)
23
+ raise Error, "Package directory not found: #{@package_dir}"
24
+ end
25
+
26
+ # Track if user explicitly specified --report-dir to avoid auto-detection override
27
+ @report_dir_override = options[:report_dir] ? :user_specified : nil
28
+
29
+ # Component initialization strategy:
30
+ # - Package mode: defer setup to run() when we're in the correct directory
31
+ # - Non-package mode: set up immediately for backward compatibility
32
+ # (callers may access @configuration after initialize)
33
+ if @package_dir
34
+ @configuration = nil
35
+ @components_initialized = false
36
+ else
37
+ setup_components
38
+ @components_initialized = true
39
+ end
40
+ end
41
+
42
+ def run
43
+ # Change to package directory if specified - this is done in run to ensure
44
+ # the ensure block always restores the directory, even on initialization errors
45
+ Dir.chdir(@package_dir) if @package_dir
46
+
47
+ # Initialize components if not already done (package mode)
48
+ setup_components unless @components_initialized
49
+
50
+ run_with_package_context
51
+ ensure
52
+ # Restore original directory if we changed it
53
+ Dir.chdir(@original_dir) if @package_dir && Dir.pwd != @original_dir
54
+ end
55
+
56
+ def run_with_package_context
57
+ validate_configuration!
58
+
59
+ start_time = Time.now
60
+
61
+ # Print package context if running in different directory
62
+ if @package_dir
63
+ puts "Running tests in #{File.basename(@package_dir)}..."
64
+ end
65
+
66
+ # Check if sequential group execution should be used
67
+ if should_execute_sequentially?
68
+ # Use default "all" group if no target specified in grouped mode
69
+ @configuration.target ||= "all"
70
+ return run_sequential_groups(start_time)
71
+ end
72
+
73
+ # Find test files
74
+ test_files = find_test_files
75
+ if test_files.empty?
76
+ return handle_no_tests
77
+ end
78
+
79
+ resolve_and_set_report_dir_context(test_files)
80
+
81
+ # Count total available test files (before filtering)
82
+ total_available = count_total_test_files
83
+
84
+ # Notify start with both counts
85
+ if @formatter.respond_to?(:on_start_with_totals)
86
+ @formatter.on_start_with_totals(test_files.size, total_available)
87
+ else
88
+ @formatter.on_start(test_files.size)
89
+ end
90
+
91
+ # Execute tests
92
+ execution_result = execute_tests(test_files)
93
+
94
+ # Check if execution failed with LoadError (stdout is empty means tests didn't run)
95
+ @parsed_result = if !execution_result[:success] && execution_result[:stdout].to_s.empty? && execution_result[:stderr] && !execution_result[:stderr].empty?
96
+ # Handle load errors or other failures that prevented test execution
97
+ {
98
+ summary: {
99
+ runs: 0,
100
+ assertions: 0,
101
+ failures: 0,
102
+ errors: test_files.size, # Count all test files as errors
103
+ skips: 0,
104
+ passed: 0
105
+ },
106
+ failures: [],
107
+ errors: [{
108
+ message: execution_result[:stderr].strip,
109
+ type: "LoadError",
110
+ files: test_files
111
+ }],
112
+ deprecations: [],
113
+ duration: execution_result[:duration]
114
+ }
115
+ elsif execution_result[:commands] && execution_result[:commands].is_a?(Array)
116
+ # Each file was executed separately, parse and sum them all
117
+ aggregate_individual_results(execution_result[:stdout])
118
+ else
119
+ # Single command execution (grouped)
120
+ @result_parser.parse_output(execution_result[:stdout])
121
+ end
122
+
123
+ # Build result object
124
+ @result = build_result(@parsed_result, execution_result, start_time)
125
+
126
+ # Analyze failures and errors
127
+ if @result.has_failures?
128
+ # Collect both failures and errors for analysis
129
+ all_failures = @parsed_result[:failures] || []
130
+
131
+ # Convert errors to failure format if present
132
+ if @parsed_result[:errors] && @parsed_result[:errors].any?
133
+ error_failures = @parsed_result[:errors].map do |error|
134
+ {
135
+ type: :error,
136
+ test_name: error[:type] || "LoadError",
137
+ message: error[:message] || "Unknown error",
138
+ location: nil,
139
+ full_content: error[:message] || "Unknown error",
140
+ files: error[:files]
141
+ }
142
+ end
143
+ all_failures += error_failures
144
+ end
145
+
146
+ analyzed_failures = @failure_analyzer.analyze_all(
147
+ all_failures,
148
+ stderr: @result.stderr
149
+ )
150
+ @result.failures_detail = analyzed_failures
151
+ end
152
+
153
+ # Generate and save report
154
+ report = @report_generator.generate(@result, test_files)
155
+
156
+ # Save reports if configured
157
+ if @configuration.save_reports
158
+ report_path = save_reports(report)
159
+ # Pass report path to formatter before outputting
160
+ @formatter.report_path = report_path if @formatter.respond_to?(:report_path=)
161
+ end
162
+
163
+ # Output to stdout
164
+ @formatter.on_finish(@result)
165
+
166
+ # Display profile results if requested
167
+ if @configuration.profile && @parsed_result[:test_times] && !@parsed_result[:test_times].empty?
168
+ display_profile(@parsed_result[:test_times], @configuration.profile)
169
+ end
170
+
171
+ # Return exit code
172
+ @result.success? ? 0 : 1
173
+ end
174
+
175
+ private
176
+
177
+ def setup_components
178
+ @configuration = build_configuration(@options)
179
+ @pattern_resolver = Molecules::PatternResolver.new(@configuration)
180
+ @test_detector = Atoms::TestDetector.new(patterns: @configuration.patterns)
181
+
182
+ # Use SmartTestExecutor for intelligent subprocess/direct execution choice
183
+ require_relative "../molecules/smart_test_executor"
184
+ force_mode = if @options[:direct]
185
+ :direct
186
+ else
187
+ (@options[:subprocess] ? :subprocess : nil)
188
+ end
189
+ @test_executor = Molecules::SmartTestExecutor.new(
190
+ timeout: @configuration.timeout,
191
+ force_mode: force_mode
192
+ )
193
+ @result_parser = Atoms::ResultParser.new
194
+ @failure_analyzer = Molecules::FailureAnalyzer.new
195
+ @report_generator = ReportGenerator.new(@configuration)
196
+
197
+ # Initialize formatter - will be updated after pattern resolution
198
+ formatter_options = @configuration.to_h
199
+ if @configuration.failure_limits
200
+ formatter_options[:max_failures_to_display] = @configuration.failure_limits[:max_display]
201
+ end
202
+ # Disable group headers in on_test_complete to avoid duplicates with on_group_start/complete
203
+ formatter_options[:show_groups] = false
204
+ @formatter = @configuration.formatter_class.new(formatter_options)
205
+ end
206
+
207
+ def run_sequential_groups(start_time)
208
+ # Resolve groups sequentially
209
+ groups = @pattern_resolver.resolve_group_sequential(@configuration.target)
210
+
211
+ if groups.empty?
212
+ return handle_no_tests
213
+ end
214
+
215
+ # Count total files
216
+ all_files = groups.flat_map { |g| g[:files] }
217
+
218
+ resolve_and_set_report_dir_context(all_files)
219
+
220
+ total_available = count_total_test_files
221
+
222
+ # Notify start
223
+ if @formatter.respond_to?(:on_start_with_totals)
224
+ @formatter.on_start_with_totals(all_files.size, total_available)
225
+ else
226
+ @formatter.on_start(all_files.size)
227
+ end
228
+
229
+ # Create sequential executor
230
+ executor = SequentialGroupExecutor.new(
231
+ test_executor: @test_executor,
232
+ result_parser: @result_parser,
233
+ formatter: @formatter
234
+ )
235
+
236
+ # Execute groups sequentially
237
+ execution_result = executor.execute_groups(groups, sequential_options) do |event|
238
+ case event[:type]
239
+ when :stdout
240
+ @formatter.on_test_stdout(event[:content]) if @formatter.respond_to?(:on_test_stdout)
241
+ when :complete
242
+ if @formatter.respond_to?(:on_test_complete)
243
+ @formatter.on_test_complete(
244
+ event[:file],
245
+ event[:success],
246
+ event[:duration]
247
+ )
248
+ end
249
+ end
250
+ end
251
+
252
+ # Use the parsed result from sequential executor
253
+ @parsed_result = execution_result[:parsed_result]
254
+
255
+ # Build result object
256
+ @result = build_result(@parsed_result, execution_result, start_time)
257
+
258
+ # Analyze failures
259
+ if @result.has_failures?
260
+ all_failures = @parsed_result[:failures] || []
261
+
262
+ if @parsed_result[:errors] && @parsed_result[:errors].any?
263
+ error_failures = @parsed_result[:errors].map do |error|
264
+ {
265
+ type: :error,
266
+ test_name: error[:type] || "LoadError",
267
+ message: error[:message] || "Unknown error",
268
+ location: nil,
269
+ full_content: error[:message] || "Unknown error",
270
+ files: error[:files]
271
+ }
272
+ end
273
+ all_failures += error_failures
274
+ end
275
+
276
+ analyzed_failures = @failure_analyzer.analyze_all(
277
+ all_failures,
278
+ stderr: @result.stderr
279
+ )
280
+ @result.failures_detail = analyzed_failures
281
+ end
282
+
283
+ # Generate and save report
284
+ report = @report_generator.generate(@result, all_files)
285
+
286
+ if @configuration.save_reports
287
+ report_path = save_reports(report)
288
+ @formatter.report_path = report_path if @formatter.respond_to?(:report_path=)
289
+ end
290
+
291
+ # Output to stdout
292
+ @formatter.on_finish(@result)
293
+
294
+ # Display profile results if requested
295
+ if @configuration.profile && @parsed_result[:test_times] && !@parsed_result[:test_times].empty?
296
+ display_profile(@parsed_result[:test_times], @configuration.profile)
297
+ end
298
+
299
+ # Show stopped message if execution was stopped
300
+ if execution_result[:stopped_at_group]
301
+ puts "\nSTOPPED: Group '#{execution_result[:stopped_at_group]}' failed (--fail-fast enabled)"
302
+ end
303
+
304
+ # Return exit code
305
+ @result.success? ? 0 : 1
306
+ end
307
+
308
+ def should_execute_sequentially?
309
+ # If explicit files are provided via CLI, bypass group execution
310
+ # This ensures that commands like `ace-test test/atoms/foo_test.rb` run only that file
311
+ return false if @configuration.files && !@configuration.files.empty?
312
+
313
+ # Only use sequential groups if execution_mode is "grouped"
314
+ return false unless @configuration.execution_mode == "grouped"
315
+
316
+ # Explicit --run-in-single-batch bypasses grouped execution
317
+ return false if @configuration.run_in_single_batch
318
+
319
+ # When profiling without a specific target, run as single batch for accurate timing
320
+ # Profiling requires verbose output which works better with all-at-once execution
321
+ if @configuration.profile && !@configuration.target
322
+ return false
323
+ end
324
+
325
+ # If no target specified, default to "all" group for grouped mode
326
+ target = @configuration.target || "all"
327
+
328
+ target_str = target.to_s
329
+ target_sym = target.to_sym
330
+
331
+ # Check both string and symbol keys for compatibility
332
+ @configuration.groups&.key?(target_str) || @configuration.groups&.key?(target_sym)
333
+ end
334
+
335
+ def sequential_options
336
+ {
337
+ fail_fast: @configuration.fail_fast,
338
+ verbose: @configuration.verbose,
339
+ per_file: @configuration.per_file,
340
+ profile: @configuration.profile,
341
+ group_fail_fast: @configuration.execution&.[](:group_fail_fast),
342
+ group_isolation: @configuration.group_isolation
343
+ }
344
+ end
345
+
346
+ def build_configuration(options)
347
+ # Load configuration from file
348
+ config_loader = Molecules::ConfigLoader.new
349
+ config_data = config_loader.load(options[:config_path])
350
+
351
+ # Merge with command-line options
352
+ config_with_options = config_loader.merge_with_options(config_data, options)
353
+
354
+ # Create TestConfiguration with merged data
355
+ Models::TestConfiguration.new(
356
+ format: config_with_options.defaults[:reporter] || options[:format],
357
+ report_dir: options[:report_dir] || config_with_options.defaults[:report_dir],
358
+ save_reports: config_with_options.defaults[:save_reports] != false && options[:save_reports] != false,
359
+ fail_fast: config_with_options.defaults[:fail_fast] || options[:fail_fast],
360
+ verbose: options[:verbose],
361
+ filter: options[:filter],
362
+ fix_deprecations: options[:fix_deprecations],
363
+ patterns: config_with_options.patterns,
364
+ groups: config_with_options.groups,
365
+ target: options[:target],
366
+ config_path: options[:config_path],
367
+ timeout: options[:timeout],
368
+ parallel: options[:parallel],
369
+ color: (config_with_options.defaults[:color] == "auto") ? true : config_with_options.defaults[:color],
370
+ per_file: options[:per_file],
371
+ failure_limits: config_with_options.failure_limits,
372
+ profile: options[:profile],
373
+ execution: config_with_options.execution || {},
374
+ files: options[:files],
375
+ run_in_single_batch: options[:run_in_single_batch]
376
+ )
377
+ end
378
+
379
+ def validate_configuration!
380
+ @configuration.validate!
381
+ rescue ArgumentError => e
382
+ puts "Configuration error: #{e.message}"
383
+ exit 1
384
+ end
385
+
386
+ def find_test_files
387
+ # If specific files are provided (e.g., from command line), use them directly
388
+ if @configuration.files && !@configuration.files.empty?
389
+ files = @configuration.files
390
+
391
+ # Don't apply filter when files contain line numbers (file:line format)
392
+ has_line_numbers = files.any? { |f| f.match?(/:\d+$/) }
393
+
394
+ # Apply filter only if no line numbers and filter is provided
395
+ if @configuration.filter && !has_line_numbers
396
+ files = @test_detector.filter_by_pattern(files, @configuration.filter)
397
+ end
398
+
399
+ return files
400
+ end
401
+
402
+ # Otherwise, use PatternResolver for consistency
403
+ begin
404
+ files = if @configuration.target
405
+ @pattern_resolver.resolve_target(@configuration.target)
406
+ else
407
+ # When no target specified, resolve all files
408
+ @pattern_resolver.resolve_target(nil)
409
+ end
410
+ rescue ArgumentError => e
411
+ puts "Error: #{e.message}"
412
+ puts "Available targets: #{@pattern_resolver.available_targets.join(", ")}"
413
+ exit 1
414
+ end
415
+
416
+ # If using catch-all pattern, reinitialize formatter without groups
417
+ if @pattern_resolver.using_catch_all
418
+ formatter_options = @configuration.to_h.merge(show_groups: false)
419
+ if @configuration.failure_limits
420
+ formatter_options[:max_failures_to_display] = @configuration.failure_limits[:max_display]
421
+ end
422
+ @formatter = @configuration.formatter_class.new(formatter_options)
423
+ end
424
+
425
+ # Apply filter if provided
426
+ if @configuration.filter
427
+ files = @test_detector.filter_by_pattern(files, @configuration.filter)
428
+ end
429
+
430
+ files
431
+ end
432
+
433
+ def handle_no_tests
434
+ @result = Models::TestResult.new
435
+
436
+ message = if @configuration.filter
437
+ "No test files found matching pattern '#{@configuration.filter}'"
438
+ else
439
+ "No test files found"
440
+ end
441
+
442
+ puts message
443
+ 0
444
+ end
445
+
446
+ def count_total_test_files
447
+ # Count all test files in test directory regardless of configuration
448
+ Dir.glob("test/**/*_test.rb").select { |f| File.file?(f) }.size
449
+ end
450
+
451
+ def execute_tests(test_files)
452
+ options = {
453
+ fail_fast: @configuration.fail_fast,
454
+ verbose: @configuration.verbose,
455
+ per_file: @configuration.per_file, # Allow per-file execution if needed for debugging
456
+ profile: @configuration.profile, # Add profile option for verbose timing
457
+ group_isolation: @configuration.group_isolation # Pass through for execution mode selection
458
+ }
459
+
460
+ # Always use execute_with_progress for consistent interface
461
+ # The method internally decides whether to run per-file or grouped
462
+ @test_executor.execute_with_progress(test_files, options) do |event|
463
+ case event[:type]
464
+ when :stdout
465
+ # Pass stdout to formatter for per-test progress
466
+ @formatter.on_test_stdout(event[:content]) if @formatter.respond_to?(:on_test_stdout)
467
+ when :complete
468
+ if @formatter.respond_to?(:on_test_complete)
469
+ @formatter.on_test_complete(
470
+ event[:file],
471
+ event[:success],
472
+ event[:duration]
473
+ )
474
+ end
475
+ end
476
+ end
477
+ end
478
+
479
+ def build_result(parsed_result, execution_result, start_time)
480
+ Models::TestResult.new(
481
+ passed: parsed_result[:summary][:passed],
482
+ failed: parsed_result[:summary][:failures],
483
+ errors: parsed_result[:summary][:errors],
484
+ skipped: parsed_result[:summary][:skips],
485
+ assertions: parsed_result[:summary][:assertions],
486
+ duration: parsed_result[:duration] || execution_result[:duration],
487
+ start_time: start_time,
488
+ end_time: Time.now,
489
+ deprecations: parsed_result[:deprecations],
490
+ raw_output: execution_result[:stdout],
491
+ stderr: execution_result[:stderr]
492
+ )
493
+ end
494
+
495
+ def aggregate_individual_results(combined_output)
496
+ # Split output by test file executions
497
+ individual_outputs = combined_output.split(/^Started with run options/)
498
+ individual_outputs.shift if individual_outputs.first && individual_outputs.first.empty?
499
+
500
+ aggregated = {
501
+ raw_output: combined_output,
502
+ summary: {
503
+ runs: 0,
504
+ assertions: 0,
505
+ failures: 0,
506
+ errors: 0,
507
+ skips: 0,
508
+ passed: 0
509
+ },
510
+ failures: [],
511
+ duration: 0.0,
512
+ deprecations: [],
513
+ test_times: []
514
+ }
515
+
516
+ individual_outputs.each do |output|
517
+ output = "Started with run options" + output # Restore the split text
518
+ parsed = @result_parser.parse_output(output)
519
+
520
+ # Sum up the counts
521
+ aggregated[:summary][:runs] += parsed[:summary][:runs]
522
+ aggregated[:summary][:assertions] += parsed[:summary][:assertions]
523
+ aggregated[:summary][:failures] += parsed[:summary][:failures]
524
+ aggregated[:summary][:errors] += parsed[:summary][:errors]
525
+ aggregated[:summary][:skips] += parsed[:summary][:skips]
526
+ aggregated[:summary][:passed] += parsed[:summary][:passed]
527
+
528
+ # Collect failures and deprecations
529
+ aggregated[:failures].concat(parsed[:failures])
530
+ aggregated[:deprecations].concat(parsed[:deprecations])
531
+ aggregated[:duration] += parsed[:duration]
532
+
533
+ # Collect test times if available
534
+ aggregated[:test_times].concat(parsed[:test_times]) if parsed[:test_times]
535
+ end
536
+
537
+ # Re-sort test times by duration
538
+ aggregated[:test_times].sort_by! { |t| -t[:duration] } if aggregated[:test_times]
539
+
540
+ aggregated
541
+ end
542
+
543
+ def resolve_and_set_report_dir_context(test_files)
544
+ explicit_cli_override = @report_dir_override == :user_specified
545
+
546
+ report_root_start_path = Dir.pwd
547
+ if explicit_cli_override && @package_dir
548
+ report_root_start_path = @original_dir
549
+ end
550
+
551
+ report_root = Atoms::ReportDirectoryResolver.resolve_report_root(
552
+ @configuration.report_dir,
553
+ explicit_cli_override: explicit_cli_override,
554
+ start_path: report_root_start_path
555
+ )
556
+
557
+ if explicit_cli_override
558
+ @configuration.report_dir = report_root
559
+ return
560
+ end
561
+
562
+ package_name = Atoms::ReportDirectoryResolver.infer_package_name(
563
+ package_dir: @package_dir,
564
+ test_files: test_files,
565
+ cwd: Dir.pwd
566
+ )
567
+
568
+ @configuration.report_dir = Atoms::ReportDirectoryResolver.resolve_package_report_dir(
569
+ report_root: report_root,
570
+ package_name: package_name
571
+ )
572
+ end
573
+
574
+ def save_reports(report)
575
+ timestamp_generator = Atoms::TimestampGenerator.new
576
+ storage = Molecules::ReportStorage.new(
577
+ base_dir: @configuration.report_dir,
578
+ timestamp_generator: timestamp_generator
579
+ )
580
+
581
+ # Save in appropriate format
582
+ report_path = case @configuration.format
583
+ when "json"
584
+ storage.save_report(report, format: :json)
585
+ else
586
+ storage.save_report(report, format: :all)
587
+ end
588
+
589
+ # Always save raw output
590
+ storage.save_raw_output(@result.raw_output, report_path)
591
+
592
+ # Save stderr if present
593
+ storage.save_stderr(@result.stderr, report_path) if @result.stderr && !@result.stderr.empty?
594
+
595
+ # Save individual failure reports if there are failures
596
+ if @result.has_failures? && @configuration.format != "json"
597
+ markdown_formatter_class = Atoms::LazyLoader.load_formatter("markdown")
598
+ markdown_formatter = markdown_formatter_class.new(@configuration.to_h)
599
+ max_display = @configuration.failure_limits ? @configuration.failure_limits[:max_display] : nil
600
+ storage.save_individual_failure_reports(
601
+ @result.failures_detail,
602
+ report_path,
603
+ markdown_formatter,
604
+ max_display: max_display
605
+ )
606
+ end
607
+
608
+ report.report_path = report_path
609
+ report_path
610
+ end
611
+
612
+ def display_profile(test_times, count)
613
+ return if test_times.empty?
614
+
615
+ # Take only the slowest N tests
616
+ slowest = test_times.first(count)
617
+
618
+ puts "\n" + "=" * 60
619
+ puts "Slowest Tests (Top #{[count, test_times.size].min})"
620
+ puts "=" * 60
621
+
622
+ slowest.each_with_index do |test, index|
623
+ # Format duration nicely
624
+ duration = format("%.3fs", test[:duration])
625
+
626
+ # Try to shorten the file path if location is available
627
+ location = if test[:location]
628
+ test[:location].gsub(/^.*\/test\//, "test/")
629
+ else
630
+ "unknown location"
631
+ end
632
+
633
+ puts format("%2d. %-50s %8s",
634
+ index + 1,
635
+ test[:name][0..49], # Truncate long test names
636
+ duration)
637
+ puts " #{location}" if test[:location]
638
+ end
639
+
640
+ # Show total time for all tests
641
+ total_time = test_times.sum { |t| t[:duration] }
642
+ puts "-" * 60
643
+ puts format("Total time in tests: %.3fs", total_time)
644
+ end
645
+ end
646
+ end
647
+ end
648
+ end