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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/test-runner/config.yml +2 -0
  3. data/CHANGELOG.md +27 -0
  4. data/exe/ace-test-suite +13 -4
  5. data/lib/ace/test_runner/atoms/command_builder.rb +60 -86
  6. data/lib/ace/test_runner/atoms/line_number_resolver.rb +70 -63
  7. data/lib/ace/test_runner/atoms/result_parser.rb +1 -1
  8. data/lib/ace/test_runner/cli/commands/test.rb +2 -3
  9. data/lib/ace/test_runner/formatters/json_formatter.rb +3 -1
  10. data/lib/ace/test_runner/formatters/progress_file_formatter.rb +10 -7
  11. data/lib/ace/test_runner/formatters/progress_formatter.rb +10 -7
  12. data/lib/ace/test_runner/models/test_report.rb +1 -0
  13. data/lib/ace/test_runner/models/test_result.rb +12 -3
  14. data/lib/ace/test_runner/models/test_selection_plan.rb +44 -0
  15. data/lib/ace/test_runner/molecules/cli_argument_parser.rb +9 -1
  16. data/lib/ace/test_runner/molecules/execution_evidence.rb +102 -0
  17. data/lib/ace/test_runner/molecules/failed_package_reporter.rb +4 -65
  18. data/lib/ace/test_runner/molecules/in_process_runner.rb +48 -37
  19. data/lib/ace/test_runner/molecules/pattern_resolver.rb +8 -5
  20. data/lib/ace/test_runner/molecules/report_storage.rb +24 -20
  21. data/lib/ace/test_runner/molecules/selection_resolver.rb +45 -0
  22. data/lib/ace/test_runner/molecules/selection_verifier.rb +78 -0
  23. data/lib/ace/test_runner/molecules/smart_test_executor.rb +4 -0
  24. data/lib/ace/test_runner/molecules/test_executor.rb +11 -4
  25. data/lib/ace/test_runner/organisms/sequential_target_executor.rb +6 -7
  26. data/lib/ace/test_runner/organisms/test_orchestrator.rb +44 -21
  27. data/lib/ace/test_runner/suite/display_manager.rb +7 -6
  28. data/lib/ace/test_runner/suite/orchestrator.rb +5 -3
  29. data/lib/ace/test_runner/suite/process_monitor.rb +37 -119
  30. data/lib/ace/test_runner/suite/result_aggregator.rb +17 -89
  31. data/lib/ace/test_runner/suite/simple_display_manager.rb +2 -1
  32. data/lib/ace/test_runner/version.rb +1 -1
  33. data/lib/ace/test_runner.rb +1 -0
  34. metadata +34 -2
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module TestRunner
5
+ module Models
6
+ class TestSelectionPlan
7
+ attr_reader :files, :identities, :source_digests
8
+
9
+ def initialize(files:, identities:, source_digests:)
10
+ @files = freeze_values(files)
11
+ @identities = freeze_values(identities)
12
+ @source_digests = freeze_values(source_digests)
13
+ freeze
14
+ end
15
+
16
+ def qualified?
17
+ !identities.empty?
18
+ end
19
+
20
+ def for_file(file)
21
+ path = File.expand_path(file.sub(/:\d+$/, ""))
22
+ self.class.new(files: [path],
23
+ identities: identities.select { |identity| identity.fetch(:file) == path },
24
+ source_digests: source_digests.select { |key, _| key == path })
25
+ end
26
+
27
+ private
28
+
29
+ def freeze_values(value)
30
+ case value
31
+ when Hash
32
+ value.to_h { |key, entry| [freeze_values(key), freeze_values(entry)] }.freeze
33
+ when Array
34
+ value.map { |entry| freeze_values(entry) }.freeze
35
+ when String
36
+ value.dup.freeze
37
+ else
38
+ value.freeze
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -38,6 +38,8 @@ module Ace
38
38
  #
39
39
  # @return [Hash] Parsed options with :package_dir, :target, :files keys
40
40
  def parse
41
+ require_relative "../atoms/line_number_resolver"
42
+ @argv.each { |argument| Atoms::LineNumberResolver.parse_file_with_line(argument) }
41
43
  parse_first_argument
42
44
  parse_remaining_arguments
43
45
 
@@ -125,6 +127,8 @@ module Ace
125
127
  @test_files << arg
126
128
  elsif package_relative_ruby_file?(arg)
127
129
  @test_files << arg
130
+ elsif literal_ruby_file?(arg)
131
+ raise ArgumentError, "File not found: #{arg}"
128
132
  elsif known_target?(arg)
129
133
  @target = arg
130
134
  elsif @target.nil? && !File.exist?(arg)
@@ -210,7 +214,7 @@ module Ace
210
214
  # Check if an argument could be a package name (not a target, file, or file:line)
211
215
  def potential_package?(arg)
212
216
  !known_target?(arg) &&
213
- !(arg.end_with?(".rb") && File.file?(arg)) &&
217
+ !literal_ruby_file?(arg) &&
214
218
  !file_with_line?(arg)
215
219
  end
216
220
 
@@ -236,6 +240,10 @@ module Ace
236
240
  @test_files << (@package_dir ? format_file_with_line(file_part, line_part) : arg)
237
241
  end
238
242
 
243
+ def literal_ruby_file?(arg)
244
+ arg.end_with?(".rb") && !arg.match?(/[\*?\[\]{}]/)
245
+ end
246
+
239
247
  def existing_ruby_file?(arg)
240
248
  File.exist?(arg) && arg.end_with?(".rb")
241
249
  end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "json"
5
+ require "fileutils"
6
+
7
+ module Ace
8
+ module TestRunner
9
+ module Molecules
10
+ # The completion file is published last, after the optional report is complete.
11
+ # A suite reads this exact file once; latest is only a navigation convenience.
12
+ class ExecutionEvidence
13
+ CHANNEL = "ACE_TEST_EXECUTION"
14
+ COUNTS = %w[total passed failed errors skipped assertions].freeze
15
+ attr_reader :identity, :path, :package, :entry_id, :package_path
16
+
17
+ def initialize(package_path:, package: nil, entry_id: nil, identity: SecureRandom.uuid, path: nil)
18
+ @identity = identity
19
+ @package_path = File.realpath(package_path)
20
+ @package = package || File.basename(@package_path)
21
+ @entry_id = entry_id || identity
22
+ @path = path || File.join(@package_path, ".ace-local/test/completions", "#{identity}.json")
23
+ end
24
+
25
+ def self.for_invocation(package_path)
26
+ channel = ENV[CHANNEL]
27
+ return new(package_path: package_path) unless channel
28
+
29
+ data = JSON.parse(channel, symbolize_names: true)
30
+ raise Error, "Execution package path mismatch" unless File.realpath(package_path) == data[:package_path]
31
+
32
+ new(**data)
33
+ end
34
+
35
+ def channel
36
+ JSON.generate(identity: identity, path: path, package: package, entry_id: entry_id, package_path: package_path)
37
+ end
38
+
39
+ def publish(result, files:, report_dir: nil)
40
+ data = {
41
+ execution_id: identity, entry_id: entry_id, package: package, package_path: package_path,
42
+ pid: Process.pid, completed: true, selected_files: files, report_dir: report_dir,
43
+ total: result.total_tests, passed: result.passed, failed: result.failed, errors: result.errors,
44
+ skipped: result.skipped, assertions: result.assertions, duration: result.duration,
45
+ success: result.success?, error: result.execution_error
46
+ }
47
+ FileUtils.mkdir_p(File.dirname(path))
48
+ temporary = "#{path}.#{SecureRandom.uuid}.tmp"
49
+ File.write(temporary, JSON.generate(data))
50
+ # Exclusive publication: a completed invocation may never be replaced.
51
+ File.link(temporary, path)
52
+ data
53
+ ensure
54
+ FileUtils.rm_f(temporary) if temporary
55
+ end
56
+
57
+ def read(pid:, save_reports:)
58
+ data = JSON.parse(File.read(path))
59
+ valid = data.is_a?(Hash) && data["completed"] == true &&
60
+ data["execution_id"] == identity && data["entry_id"] == entry_id &&
61
+ data["package"] == package && data["package_path"] == package_path && data["pid"] == pid &&
62
+ data["selected_files"].is_a?(Array) && data["selected_files"].all? { |file| file.is_a?(String) } &&
63
+ COUNTS.all? { |key| data[key].is_a?(Integer) && data[key] >= 0 } &&
64
+ [true, false].include?(data["success"]) && data["duration"].is_a?(Numeric) &&
65
+ data["duration"].finite? && data["duration"] >= 0 &&
66
+ data["total"] == data.values_at("passed", "failed", "errors", "skipped").sum &&
67
+ (!data["success"] || data["failed"] + data["errors"] == 0) &&
68
+ (!data["selected_files"].empty? || data["total"] == 0) &&
69
+ (!data["success"] || data["total"] > 0 || data["selected_files"].empty?)
70
+ raise Error, "Invalid or mismatched execution completion evidence" unless valid
71
+
72
+ if save_reports
73
+ validate_report!(data)
74
+ elsif data["report_dir"]
75
+ raise Error, "Unexpected saved report in no-save execution"
76
+ end
77
+ data.transform_keys(&:to_sym)
78
+ rescue SystemCallError, JSON::ParserError, TypeError => e
79
+ raise Error, "Unverifiable execution completion evidence: #{e.message}"
80
+ end
81
+
82
+ private
83
+
84
+ def validate_report!(data)
85
+ directory = data["report_dir"]
86
+ raise Error, "Missing concrete execution report directory" unless directory.is_a?(String) &&
87
+ File.absolute_path(directory) == directory && File.basename(directory) == identity && !File.symlink?(directory)
88
+
89
+ summary = JSON.parse(File.read(File.join(directory, "summary.json")))
90
+ report = JSON.parse(File.read(File.join(directory, "report.json")))
91
+ raise Error, "Mismatched execution summary/report" unless summary.is_a?(Hash) && report.is_a?(Hash) &&
92
+ summary["execution_id"] == identity && report.dig("metadata", "execution_id") == identity &&
93
+ report["report_path"] == directory && report["files_tested"] == data["selected_files"] &&
94
+ report.dig("result", "total_tests") == data["total"] &&
95
+ COUNTS.all? { |key| summary[key] == data[key] } && summary["success"] == data["success"] &&
96
+ summary["duration"] == data["duration"] &&
97
+ %w[passed failed errors skipped assertions duration success].all? { |key| report.dig("result", key) == data[key] }
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -10,79 +10,18 @@ module Ace
10
10
  module_function
11
11
 
12
12
  def format_for_display(package)
13
- path = extract_path(package)
14
- report_root = extract_report_root(package)
15
- package_name = extract_name(package) || File.basename(path)
16
- report_path = Atoms::ReportPathResolver.call(
17
- path,
18
- report_root: report_root,
19
- package_name: package_name
20
- )
21
-
22
- if report_path
23
- begin
24
- relative_path = Pathname.new(report_path).relative_path_from(Dir.pwd)
25
- " → See #{relative_path}"
26
- rescue => e
27
- warn "Failed to calculate relative path: #{e.message}" if debug_mode?
28
- " → See #{report_path}"
29
- end
30
- else
31
- reports_path = fallback_reports_path(path, report_root, package_name)
32
- begin
33
- relative_path = Pathname.new(reports_path).relative_path_from(Dir.pwd)
34
- " → Check #{relative_path}/ for details"
35
- rescue => e
36
- warn "Failed to calculate relative path: #{e.message}" if debug_mode?
37
- " → Check #{reports_path}/ for details"
38
- end
39
- end
13
+ directory = package[:report_dir] || package["report_dir"]
14
+ directory ? " → See #{relative_or_absolute(directory)}" : " → No saved report"
40
15
  end
41
16
 
42
17
  def format_for_markdown(package)
43
- path = extract_path(package)
44
- report_root = extract_report_root(package)
45
- package_name = extract_name(package) || File.basename(path)
46
- report_path = Atoms::ReportPathResolver.call(
47
- path,
48
- report_root: report_root,
49
- package_name: package_name
50
- )
51
-
52
- if report_path
53
- relative_report_path = relative_or_absolute(report_path)
54
- "- Report: `#{relative_report_path}`"
55
- else
56
- reports_path = fallback_reports_path(path, report_root, package_name)
57
- relative_reports_path = relative_or_absolute(reports_path)
58
- "- Report: Check `#{relative_reports_path}/` for details"
59
- end
18
+ directory = package[:report_dir] || package["report_dir"]
19
+ directory ? "- Report: `#{relative_or_absolute(directory)}`" : "- No saved report"
60
20
  end
61
21
 
62
22
  class << self
63
23
  private
64
24
 
65
- def extract_path(package)
66
- package[:path] || package["path"]
67
- end
68
-
69
- def extract_name(package)
70
- package[:name] || package["name"]
71
- end
72
-
73
- def extract_report_root(package)
74
- package[:report_root] || package["report_root"]
75
- end
76
-
77
- def fallback_reports_path(path, report_root, package_name)
78
- if report_root
79
- short_name = package_name.to_s.sub(/\Aace-/, "")
80
- return File.join(report_root, short_name) unless short_name.empty?
81
- end
82
-
83
- File.join(path, "test-reports")
84
- end
85
-
86
25
  def debug_mode?
87
26
  ENV["DEBUG"]
88
27
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "stringio"
4
+ require "timeout"
4
5
  require "ostruct"
5
6
  require "minitest"
6
7
 
@@ -10,6 +11,16 @@ module Ace
10
11
  # Runs tests directly in the current Ruby process without spawning subprocesses
11
12
  # This provides significantly faster execution for unit tests that don't need isolation
12
13
  class InProcessRunner
14
+ # Minitest converts ordinary timeout exceptions into completed test
15
+ # errors. Its pass-through SystemExit boundary preserves execution
16
+ # deadlines; this owner catches the private control-flow exception.
17
+ class ExecutionTimeout < SystemExit
18
+ def initialize(*)
19
+ super(124)
20
+ end
21
+ end
22
+ private_constant :ExecutionTimeout
23
+
13
24
  def initialize(timeout: nil, launch_env:)
14
25
  @timeout = timeout
15
26
  @launch_env = launch_env # Hermetic fixture environment applied for the test run
@@ -52,30 +63,11 @@ module Ace
52
63
  require "minitest/autorun"
53
64
  end
54
65
 
55
- # First pass: check for line numbers and resolve test names
56
- # This must be done before loading files
57
- test_names_to_run = []
58
- files_to_load = []
59
-
60
- files.each do |file|
61
- # Check if file has line number (file:line format)
62
- if file =~ /^(.+):(\d+)$/
63
- actual_file = $1
64
- line_number = $2.to_i
65
-
66
- # Resolve line number to test name
67
- require_relative "../atoms/line_number_resolver"
68
- test_name = Ace::TestRunner::Atoms::LineNumberResolver.resolve_test_at_line(actual_file, line_number)
69
- test_names_to_run << test_name if test_name
70
-
71
- files_to_load << actual_file
72
- else
73
- files_to_load << file
74
- end
75
- end
76
-
77
- # Store test names in options to pass to run_minitest_with_args
78
- options = options.merge(test_names_filter: test_names_to_run) if test_names_to_run.any?
66
+ require_relative "selection_resolver"
67
+ require_relative "selection_verifier"
68
+ selection = options[:selection_plan] || SelectionResolver.resolve(files)
69
+ SelectionVerifier.verify_sources!(selection)
70
+ files_to_load = selection.files
79
71
 
80
72
  # Clear previously loaded test classes to avoid accumulation between groups
81
73
  # This is crucial for in-process execution where tests from previous groups
@@ -118,18 +110,27 @@ module Ace
118
110
  end
119
111
  end
120
112
 
113
+ if selection.qualified?
114
+ options = options.merge(selection_pattern: SelectionVerifier.verify_loaded!(selection))
115
+ end
116
+
121
117
  # Run Minitest with captured output
122
118
  # Suppress Minitest's own output by using null reporter
123
- exit_code = if @timeout
124
- Timeout.timeout(@timeout) do
119
+ execution = lambda do
120
+ if @timeout
121
+ Timeout.timeout(@timeout, ExecutionTimeout) { run_minitest_silent(options) }
122
+ else
125
123
  run_minitest_silent(options)
126
124
  end
125
+ end
126
+ exit_code = if selection.qualified?
127
+ SelectionVerifier.verify_execution!(selection, &execution)
127
128
  else
128
- run_minitest_silent(options)
129
+ execution.call
129
130
  end
130
131
 
131
132
  success = exit_code == true || exit_code == 0
132
- rescue Timeout::Error
133
+ rescue ExecutionTimeout, Timeout::Error
133
134
  stderr_io.puts "Test execution timed out after #{@timeout} seconds"
134
135
  success = false
135
136
  exit_code = 124
@@ -232,9 +233,8 @@ module Ace
232
233
  args << "--verbose" if options[:verbose]
233
234
 
234
235
  # Add test name filter if line numbers were provided
235
- if options[:test_names_filter] && options[:test_names_filter].any?
236
- pattern = options[:test_names_filter].map { |name| Regexp.escape(name) }.join("|")
237
- args << "--name" << "/#{pattern}/"
236
+ if options[:selection_pattern]
237
+ args << "--name" << "/#{options[:selection_pattern]}/"
238
238
  end
239
239
 
240
240
  # Run Minitest
@@ -251,14 +251,25 @@ module Ace
251
251
  args << "--seed" << options[:seed].to_s if options[:seed]
252
252
 
253
253
  # Add test name filter if line numbers were provided
254
- if options[:test_names_filter] && options[:test_names_filter].any?
255
- pattern = options[:test_names_filter].map { |name| Regexp.escape(name) }.join("|")
256
- args << "--name" << "/#{pattern}/"
254
+ if options[:selection_pattern]
255
+ args << "--name" << "/#{options[:selection_pattern]}/"
257
256
  end
258
257
 
259
- # Reporter already set up before loading test files
260
- # Run Minitest
261
- Minitest.run(args)
258
+ # Minitest swallows Interrupt and returns a normal failure. Preserve
259
+ # operator SIGINT as interruption so the owner never writes a completed
260
+ # report for this invocation.
261
+ interrupted = false
262
+ previous_handler = Signal.trap("INT") do
263
+ interrupted = true
264
+ raise Interrupt
265
+ end
266
+ begin
267
+ result = Minitest.run(args)
268
+ raise Interrupt if interrupted
269
+ result
270
+ ensure
271
+ Signal.trap("INT", previous_handler)
272
+ end
262
273
  end
263
274
  end
264
275
  end
@@ -5,7 +5,8 @@ module Ace
5
5
  module Molecules
6
6
  class PatternResolver
7
7
  INTERNAL_PATTERN_LABELS = {
8
- "feat_tests" => "feat"
8
+ "feat_tests" => "feat",
9
+ "fast_tests" => "fast"
9
10
  }.freeze
10
11
 
11
12
  attr_reader :using_catch_all
@@ -42,7 +43,7 @@ module Ace
42
43
  targets.flat_map { |target| resolve_target(target) }.uniq
43
44
  end
44
45
 
45
- def resolve_target_sequential(target_name, seen = [])
46
+ def resolve_target_sequential(target_name, seen = [], selected = {})
46
47
  target_key = target_name.to_s
47
48
  if seen.include?(target_key)
48
49
  raise ArgumentError, "Cyclic test target definition detected: #{(seen + [target_key]).join(' -> ')}"
@@ -55,12 +56,14 @@ module Ace
55
56
  member_key = member.to_s
56
57
 
57
58
  if @targets.key?(member_key)
58
- resolve_target_sequential(member_key, seen + [target_key])
59
+ resolve_target_sequential(member_key, seen + [target_key], selected)
59
60
  elsif @patterns.key?(member_key)
60
- files = expand_pattern(@patterns[member_key])
61
+ files = expand_pattern(@patterns[member_key]).reject { |file| selected.key?(file) }
62
+ files.each { |file| selected[file] = true }
61
63
  files.empty? ? [] : [{name: display_name_for(member_key), files: files}]
62
64
  else
63
- files = expand_pattern(member)
65
+ files = expand_pattern(member).reject { |file| selected.key?(file) }
66
+ files.each { |file| selected[file] = true }
64
67
  files.empty? ? [] : [{name: "other", files: files}]
65
68
  end
66
69
  end
@@ -1,22 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module Ace
4
6
  module TestRunner
5
7
  module Molecules
6
8
  # Handles storage of test reports to filesystem
7
9
  class ReportStorage
8
- def initialize(base_dir: ".ace-local/test/reports", timestamp_generator: nil)
10
+ def initialize(base_dir: ".ace-local/test/reports")
9
11
  @base_dir = base_dir
10
- @timestamp_generator = timestamp_generator || Atoms::TimestampGenerator.new
11
12
  end
12
13
 
13
- def save_report(report, format: :json)
14
+ def reserve(execution_id = SecureRandom.uuid)
15
+ ensure_base_directory
16
+ directory = File.join(File.expand_path(@base_dir), execution_id)
17
+ Dir.mkdir(directory)
18
+ directory
19
+ end
20
+
21
+ def save_report(report, format: :json, report_dir: nil)
14
22
  ensure_base_directory
15
- report_dir = create_report_directory
23
+ report_dir ||= reserve
24
+ report.report_path = report_dir
25
+ report.metadata[:execution_id] = File.basename(report_dir)
16
26
 
17
27
  case format
18
28
  when :json
19
29
  save_json_report(report, report_dir)
30
+ save_summary(report.result, report_dir)
20
31
  when :markdown
21
32
  save_markdown_report(report, report_dir)
22
33
  when :all
@@ -50,6 +61,8 @@ module Ace
50
61
  summary_file = File.join(report_dir, "summary.json")
51
62
 
52
63
  summary_data = {
64
+ execution_id: File.basename(report_dir),
65
+ assertions: result.assertions,
53
66
  passed: result.passed,
54
67
  failed: result.failed,
55
68
  errors: result.errors,
@@ -61,7 +74,7 @@ module Ace
61
74
  timestamp: Time.now.iso8601
62
75
  }
63
76
 
64
- File.write(summary_file, JSON.pretty_generate(summary_data))
77
+ File.open(summary_file, "wx") { |file| file.write(JSON.pretty_generate(summary_data)) }
65
78
  summary_file
66
79
  end
67
80
 
@@ -211,27 +224,18 @@ module Ace
211
224
  FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
212
225
  end
213
226
 
214
- def create_report_directory
215
- ensure_base_directory
216
- timestamp = @timestamp_generator.directory_name
217
- report_dir = File.join(@base_dir, timestamp)
218
- FileUtils.mkdir_p(report_dir)
219
- report_dir
220
- end
221
-
222
227
  def create_latest_symlink(report_dir)
223
228
  latest_link = File.join(@base_dir, "latest")
224
-
225
- # Remove existing symlink if present
226
- FileUtils.rm_f(latest_link) if File.exist?(latest_link)
227
-
228
- # Create new symlink to the latest report
229
- FileUtils.ln_s(File.basename(report_dir), latest_link)
229
+ temporary = "#{latest_link}.#{SecureRandom.uuid}"
230
+ File.symlink(File.basename(report_dir), temporary)
231
+ File.rename(temporary, latest_link)
232
+ ensure
233
+ FileUtils.rm_f(temporary) if temporary
230
234
  end
231
235
 
232
236
  def save_json_report(report, report_dir)
233
237
  report_file = File.join(report_dir, "report.json")
234
- File.write(report_file, report.to_json)
238
+ File.open(report_file, "wx") { |file| file.write(report.to_json) }
235
239
  report_file
236
240
  end
237
241
 
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "../atoms/line_number_resolver"
5
+ require_relative "../models/test_selection_plan"
6
+
7
+ module Ace
8
+ module TestRunner
9
+ module Molecules
10
+ module SelectionResolver
11
+ module_function
12
+
13
+ def resolve(files)
14
+ parsed = Array(files).map { |selector| Atoms::LineNumberResolver.parse_file_with_line(selector) }
15
+ qualified = parsed.count { |entry| entry[:line] }
16
+ if qualified.positive? && qualified != parsed.size
17
+ raise Atoms::LineNumberResolver::SelectionError,
18
+ "Mixed whole-file and file:line selectors are unsupported: #{files.join(', ')}"
19
+ end
20
+ if qualified.zero?
21
+ return Models::TestSelectionPlan.new(files: Array(files), identities: [], source_digests: {})
22
+ end
23
+
24
+ sources = {}
25
+ identities = parsed.map do |entry|
26
+ path = File.expand_path(entry.fetch(:file))
27
+ source = sources[path] ||= File.read(path)
28
+ Atoms::LineNumberResolver.resolve_identity(path, entry.fetch(:line), source: source)
29
+ rescue SystemCallError => error
30
+ raise Atoms::LineNumberResolver::SelectionError,
31
+ "Cannot read test selector #{entry[:file]}:#{entry[:line]}: #{error.class}"
32
+ end
33
+ identities = identities.uniq { |entry| entry.values_at(:file, :class_name, :name) }
34
+ identities.group_by { |entry| entry.values_at(:class_name, :name) }.each_value do |matches|
35
+ next if matches.size == 1
36
+ selectors = matches.map { |entry| "#{entry[:file]}:#{entry[:start_line]}" }.join(", ")
37
+ raise Atoms::LineNumberResolver::SelectionError, "Ambiguous test identity across files: #{selectors}"
38
+ end
39
+ Models::TestSelectionPlan.new(files: sources.keys, identities: identities,
40
+ source_digests: sources.transform_values { |source| Digest::SHA256.hexdigest(source) })
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+ require "digest"
3
+ require "minitest/reporters"
4
+ require_relative "../atoms/line_number_resolver"
5
+
6
+ module Ace
7
+ module TestRunner
8
+ module Molecules
9
+ module SelectionVerifier
10
+ class ExecutionCollector < Minitest::Reporters::BaseReporter
11
+ attr_reader :identities
12
+
13
+ def initialize
14
+ super
15
+ @identities = []
16
+ @mutex = Mutex.new
17
+ end
18
+
19
+ def record(result)
20
+ @mutex.synchronize do
21
+ super
22
+ @identities << [result.class_name, result.name]
23
+ end
24
+ end
25
+ end
26
+
27
+ module_function
28
+
29
+ def verify_execution!(plan)
30
+ previous = Minitest::Reporters.reporters
31
+ collector = ExecutionCollector.new
32
+ Minitest::Reporters.reporters = Array(previous) + [collector]
33
+ result = yield
34
+ expected = plan.identities.map { |identity| identity.values_at(:class_name, :name) }.sort
35
+ unless collector.identities.sort == expected
36
+ raise Atoms::LineNumberResolver::SelectionError,
37
+ "Executed test identities do not match selected identities"
38
+ end
39
+ result
40
+ ensure
41
+ Minitest::Reporters.reporters = previous
42
+ end
43
+
44
+ def verify_sources!(plan)
45
+ plan.source_digests.each do |file, digest|
46
+ unless Digest::SHA256.file(file).hexdigest == digest
47
+ raise Atoms::LineNumberResolver::SelectionError, "Selected test source changed: #{file}"
48
+ end
49
+ end
50
+ end
51
+
52
+ def verify_loaded!(plan, runnables: Minitest::Runnable.runnables)
53
+ verify_sources!(plan)
54
+ # Minitest's default randomized enumeration requires a seed even before run.
55
+ Minitest.seed ||= Random.new_seed
56
+ plan.identities.each do |identity|
57
+ matches = runnables.select { |klass| klass.name == identity.fetch(:class_name) }
58
+ klass = matches.first
59
+ valid = matches.size == 1 && klass < Minitest::Test && klass.public_instance_methods.include?(identity.fetch(:name).to_sym)
60
+ valid &&= klass.runnable_methods.count(identity.fetch(:name)) == 1
61
+ if valid
62
+ method = klass.instance_method(identity.fetch(:name))
63
+ source = method.source_location
64
+ valid = method.owner == klass && source &&
65
+ File.expand_path(source.first) == identity.fetch(:file) && source.last == identity.fetch(:method_line)
66
+ end
67
+ unless valid
68
+ raise Atoms::LineNumberResolver::SelectionError,
69
+ "Selected test identity does not match loaded runnable: #{identity[:class_name]}##{identity[:name]}"
70
+ end
71
+ end
72
+ names = plan.identities.map { |identity| Regexp.escape("#{identity[:class_name]}##{identity[:name]}") }
73
+ "\\A(?:#{names.join('|')})\\z"
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end