ace-test-runner 0.18.1 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97801800374ba4230c6d4821d63ebf69ea2f9376a85173538fe0fd9c96bd66d8
4
- data.tar.gz: 447e4b7f920d3e0188e81c56bfe3226c5c6ac1688a72f08b5a3a2bfb3303048e
3
+ metadata.gz: 2e03df42988ad85fabb842d9f7a5c5c018fbe14d70e085b9892a234bd20edc84
4
+ data.tar.gz: 351b02463eee937bb30257ffcec182160b1925ac63a3347585fc335e7e12a0d5
5
5
  SHA512:
6
- metadata.gz: 1b0e98327d7a0a312a604750106abb08c9ae498b2c354464ce3c1af31d9b5be1bf60203c19882a00233d6affab895b6f2dc3806fbb7f8013b891dbf34eb0f0aa
7
- data.tar.gz: 06257fccd1041208bb10bc6ef7b0ccf22f05468d864ca354e371c88ab0eb1200e6a158766e3a625b46d1d7f1d89d78fa9dcbf0a040a705ad415fe7c56cacbe52
6
+ metadata.gz: 1113c8e85abf9352bf8bfeb10e1d563bb197a5fedebacd3bd934b945a9830ad312c2e830e0b93cd09d4bf038e9f4fd113b7179fbce210e0b8bc801a4d58cf29b
7
+ data.tar.gz: 5c14a5221c6c4f1af586eb8c7811a007d555a09697a491d3578e45ca99e53a3047c620a756aaed53ab31df6be082c7756caa590dbfed258fbd810be3d02325c9
data/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.19.1] - 2026-03-29
11
+
12
+ ### Technical
13
+ - Normalized published gem metadata so RubyGems and Ruby Toolbox use current release information instead of the 1980 fallback date.
14
+
15
+ ### Added
16
+ - **ace-test-runner v0.19.0**: Added suite-level per-package timeout support via `ace-test-suite --timeout` and `test_suite.timeout`, allowing hung package runs to fail and free their worker slot instead of stalling the rest of the suite.
17
+
18
+ ### Fixed
19
+ - **ace-test-runner v0.19.0**: Terminate active package process groups on suite timeout or interrupt so `ace-test-suite` no longer leaves orphaned `ace-test` subprocesses behind after stuck or cancelled runs.
10
20
 
11
21
  ## [0.18.1] - 2026-03-29
12
22
 
data/exe/ace-test-suite CHANGED
@@ -38,6 +38,10 @@ parser = OptionParser.new do |opts|
38
38
  options[:group] = group
39
39
  end
40
40
 
41
+ opts.on("-t", "--timeout SEC", Integer, "Per-package timeout in seconds") do |timeout|
42
+ options[:timeout] = timeout
43
+ end
44
+
41
45
  opts.on("-v", "--verbose", "Show verbose output") do
42
46
  options[:verbose] = true
43
47
  end
@@ -57,6 +61,7 @@ parser = OptionParser.new do |opts|
57
61
  puts " $ ace-test-suite Run all (simple line-by-line output)"
58
62
  puts " $ ace-test-suite --progress Show live animated progress bars"
59
63
  puts " $ ace-test-suite --parallel 16 Run with 16 parallel processes"
64
+ puts " $ ace-test-suite --timeout 10 Fail packages that run over 10s"
60
65
  puts " $ ace-test-suite --group foundation Run only foundation group"
61
66
  puts " $ ace-test-suite --verbose Show detailed test output"
62
67
  exit 0
@@ -110,6 +115,10 @@ if options[:parallel]
110
115
  config["test_suite"]["max_parallel"] = options[:parallel]
111
116
  end
112
117
 
118
+ if options[:timeout]
119
+ config["test_suite"]["timeout"] = options[:timeout]
120
+ end
121
+
113
122
  if options[:group]
114
123
  config["test_suite"]["packages"].select! { |p| p["group"] == options[:group] }
115
124
  if config["test_suite"]["packages"].empty?
@@ -33,7 +33,10 @@ module Ace
33
33
  test_options["report_dir"] = report_root if report_root
34
34
 
35
35
  display_manager = create_display_manager
36
- process_monitor = ProcessMonitor.new(@config.dig("test_suite", "max_parallel") || 10)
36
+ process_monitor = ProcessMonitor.new(
37
+ @config.dig("test_suite", "max_parallel") || 10,
38
+ package_timeout: @config.dig("test_suite", "timeout")
39
+ )
37
40
 
38
41
  # Enrich packages with historical duration data for scheduling
39
42
  estimator = DurationEstimator.new(report_root: report_root)
@@ -69,13 +72,19 @@ module Ace
69
72
  display_manager.show_final_results
70
73
 
71
74
  # Aggregate results
72
- aggregator = ResultAggregator.new(@packages, report_root: report_root)
75
+ aggregator = ResultAggregator.new(@packages, report_root: report_root, runtime_results: @results)
73
76
  summary = aggregator.aggregate
74
77
 
75
78
  display_manager.show_summary(summary)
76
79
 
77
80
  # Return exit code based on results
78
81
  (summary[:packages_failed] > 0) ? 1 : 0
82
+ rescue Interrupt
83
+ process_monitor&.stop_all(reason: :interrupt)
84
+ raise
85
+ rescue StandardError
86
+ process_monitor&.stop_all(reason: :error)
87
+ raise
79
88
  end
80
89
 
81
90
  private
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "open3"
4
4
  require "json"
5
+ require "timeout"
5
6
 
6
7
  module Ace
7
8
  module TestRunner
@@ -9,8 +10,11 @@ module Ace
9
10
  class ProcessMonitor
10
11
  attr_reader :processes, :max_parallel
11
12
 
12
- def initialize(max_parallel = 10)
13
+ def initialize(max_parallel = 10, package_timeout: nil, termination_grace_period: 1.0, clock: nil)
13
14
  @max_parallel = max_parallel
15
+ @package_timeout = package_timeout
16
+ @termination_grace_period = termination_grace_period
17
+ @clock = clock || -> { Time.now }
14
18
  @processes = {}
15
19
  @queue = []
16
20
  @completed = []
@@ -33,8 +37,8 @@ module Ace
33
37
  "ACE_ASSIGN_ID" => nil,
34
38
  "ACE_ASSIGN_FORK_ROOT" => nil
35
39
  })
36
- start_time = Time.now
37
- stdin, stdout, stderr, thread = Open3.popen3(env, cmd, chdir: package["path"])
40
+ start_time = now
41
+ stdin, stdout, stderr, thread = Open3.popen3(env, *cmd, chdir: package["path"], pgroup: true)
38
42
 
39
43
  @processes[package["name"]] = {
40
44
  package: package,
@@ -45,10 +49,18 @@ module Ace
45
49
  start_time: start_time,
46
50
  callback: callback,
47
51
  output: +"",
52
+ stderr_output: +"",
48
53
  report_root: test_options["report_dir"],
49
54
  test_count: 0,
50
55
  tests_run: 0,
51
- dots: +""
56
+ dots: +"",
57
+ timeout: @package_timeout,
58
+ pid: thread.pid,
59
+ pgid: thread.pid,
60
+ terminating: false,
61
+ timeout_triggered: false,
62
+ terminated_by: nil,
63
+ terminate_at: nil
52
64
  }
53
65
 
54
66
  # Initial callback
@@ -61,96 +73,32 @@ module Ace
61
73
  thread = process_info[:thread]
62
74
  callback = process_info[:callback]
63
75
 
64
- # Read available output
65
- begin
66
- if IO.select([process_info[:stdout]], nil, nil, 0)
67
- chunk = process_info[:stdout].read_nonblock(4096)
68
- process_info[:output] << chunk
69
-
70
- # Parse progress from output
71
- parse_progress(process_info, chunk)
72
-
73
- # Update display with progress
74
- if callback
75
- elapsed = Time.now - process_info[:start_time]
76
- callback.call(package, {
77
- status: :running,
78
- progress: process_info[:tests_run],
79
- total: process_info[:test_count],
80
- dots: process_info[:dots],
81
- elapsed: elapsed
82
- }, chunk)
83
- end
84
- end
85
- rescue IO::WaitReadable, EOFError
86
- # No data available or stream closed
76
+ stdout_chunk = drain_stream(process_info[:stdout], process_info[:output])
77
+ stderr_chunk = drain_stream(process_info[:stderr], process_info[:stderr_output])
78
+
79
+ parse_progress(process_info, stdout_chunk) if stdout_chunk && !stdout_chunk.empty?
80
+
81
+ if callback && ((stdout_chunk && !stdout_chunk.empty?) || (stderr_chunk && !stderr_chunk.empty?))
82
+ elapsed = now - process_info[:start_time]
83
+ callback.call(package, {
84
+ status: :running,
85
+ progress: process_info[:tests_run],
86
+ total: process_info[:test_count],
87
+ dots: process_info[:dots],
88
+ elapsed: elapsed
89
+ }, stdout_chunk)
87
90
  end
88
91
 
92
+ enforce_timeout(process_info)
93
+
89
94
  # Check if process completed
90
95
  unless thread.alive?
91
- elapsed = Time.now - process_info[:start_time]
96
+ elapsed = now - process_info[:start_time]
92
97
  exit_status = thread.value.exitstatus
93
98
 
94
- # Get final output
95
- remaining_output = begin
96
- process_info[:stdout].read
97
- rescue
98
- ""
99
- end
100
- process_info[:output] << remaining_output
101
-
102
- # Try to get accurate results from summary.json first
103
- results = nil
104
- reports_dir = Atoms::ReportPathResolver.report_directory(
105
- package["path"],
106
- report_root: process_info[:report_root],
107
- package_name: package["name"]
108
- )
109
- summary_file = reports_dir ? File.join(reports_dir, "summary.json") : nil
110
- if summary_file && File.exist?(summary_file)
111
- begin
112
- json_data = JSON.parse(File.read(summary_file))
113
- results = {
114
- tests: json_data["total"] || 0,
115
- assertions: json_data["assertions"] || 0,
116
- failures: json_data["failed"] || 0,
117
- errors: json_data["errors"] || 0,
118
- duration: json_data["duration"] || elapsed,
119
- success: json_data["success"] || false
120
- }
121
-
122
- # Also try to get assertions from report.json if not in summary
123
- if results[:assertions] == 0
124
- report_file = File.join(reports_dir, "report.json")
125
- if File.exist?(report_file)
126
- report_data = JSON.parse(File.read(report_file))
127
- results[:assertions] = report_data.dig("result", "assertions") || 0
128
- end
129
- end
130
- rescue JSON::ParserError
131
- # Fall back to parsing output
132
- end
133
- end
134
-
135
- # Fall back to parsing output if no JSON data
136
- results ||= parse_results(process_info[:output])
137
-
138
- # Close streams
139
- begin
140
- process_info[:stdout].close
141
- rescue
142
- nil
143
- end
144
- begin
145
- process_info[:stderr].close
146
- rescue
147
- nil
148
- end
149
- begin
150
- process_info[:stdin].close
151
- rescue
152
- nil
153
- end
99
+ collect_remaining_output(process_info)
100
+ results = build_results(process_info, elapsed)
101
+ close_streams(process_info)
154
102
 
155
103
  # Final callback
156
104
  if callback
@@ -164,6 +112,8 @@ module Ace
164
112
  success: success_status,
165
113
  exit_code: exit_status,
166
114
  elapsed: elapsed,
115
+ timed_out: process_info[:timeout_triggered],
116
+ interrupted: process_info[:terminated_by] == :interrupt,
167
117
  results: results
168
118
  }, process_info[:output])
169
119
  end
@@ -188,6 +138,38 @@ module Ace
188
138
  !@processes.empty? || !@queue.empty?
189
139
  end
190
140
 
141
+ def stop_all(reason: :interrupt)
142
+ @queue.clear
143
+
144
+ @processes.each_value do |process_info|
145
+ terminate_process_group(process_info, signal: "TERM", reason: reason)
146
+ end
147
+
148
+ deadline = now + @termination_grace_period
149
+ while @processes.values.any? { |info| info[:thread].alive? } && now < deadline
150
+ sleep 0.05
151
+ end
152
+
153
+ @processes.each_value do |process_info|
154
+ next unless process_info[:thread].alive?
155
+
156
+ terminate_process_group(process_info, signal: "KILL", reason: reason)
157
+ end
158
+
159
+ @processes.each_value do |process_info|
160
+ begin
161
+ Timeout.timeout(0.5) { process_info[:thread].value if process_info[:thread].alive? }
162
+ rescue Timeout::Error, StandardError
163
+ nil
164
+ end
165
+
166
+ close_streams(process_info)
167
+ end
168
+
169
+ @processes.clear
170
+ @completed.clear
171
+ end
172
+
191
173
  def wait_all
192
174
  while running?
193
175
  check_processes
@@ -219,10 +201,144 @@ module Ace
219
201
  cmd_parts << "--report-dir" << pkg_report_dir
220
202
  end
221
203
 
222
- # Build command string
223
- # Note: Do NOT set CI=true here - respect the existing environment
224
- # Tests that need CI-aware behavior should check ENV['CI'] directly
225
- cmd_parts.join(" ")
204
+ cmd_parts
205
+ end
206
+
207
+ def now
208
+ @clock.call
209
+ end
210
+
211
+ def enforce_timeout(process_info)
212
+ timeout = process_info[:timeout]
213
+ return unless timeout
214
+
215
+ elapsed = now - process_info[:start_time]
216
+
217
+ if !process_info[:timeout_triggered] && elapsed > timeout
218
+ process_info[:timeout_triggered] = true
219
+ process_info[:terminate_at] = now + @termination_grace_period
220
+ terminate_process_group(process_info, signal: "TERM", reason: :timeout)
221
+ elsif process_info[:timeout_triggered] && process_info[:thread].alive? && process_info[:terminate_at] && now >= process_info[:terminate_at]
222
+ terminate_process_group(process_info, signal: "KILL", reason: :timeout)
223
+ process_info[:terminate_at] = nil
224
+ end
225
+ end
226
+
227
+ def terminate_process_group(process_info, signal:, reason:)
228
+ process_info[:terminated_by] = reason
229
+ process_info[:terminating] = true
230
+ Process.kill(signal, -process_info[:pgid])
231
+ rescue Errno::ESRCH, Errno::EPERM
232
+ nil
233
+ end
234
+
235
+ def drain_stream(io, buffer)
236
+ return nil unless io && !io.closed?
237
+
238
+ chunk = +""
239
+ loop do
240
+ ready = IO.select([io], nil, nil, 0)
241
+ break unless ready
242
+
243
+ chunk << io.read_nonblock(4096)
244
+ end
245
+
246
+ buffer << chunk unless chunk.empty?
247
+ chunk
248
+ rescue IO::WaitReadable, EOFError
249
+ buffer << chunk unless chunk.empty?
250
+ chunk
251
+ rescue IOError
252
+ nil
253
+ end
254
+
255
+ def collect_remaining_output(process_info)
256
+ process_info[:output] << safe_read(process_info[:stdout])
257
+ process_info[:stderr_output] << safe_read(process_info[:stderr])
258
+ end
259
+
260
+ def safe_read(io)
261
+ return "" unless io && !io.closed?
262
+
263
+ io.read
264
+ rescue StandardError
265
+ ""
266
+ end
267
+
268
+ def close_streams(process_info)
269
+ %i[stdout stderr stdin].each do |stream|
270
+ begin
271
+ process_info[stream]&.close
272
+ rescue StandardError
273
+ nil
274
+ end
275
+ end
276
+ end
277
+
278
+ def build_results(process_info, elapsed)
279
+ return timeout_results(process_info, elapsed) if process_info[:timeout_triggered]
280
+ return interrupted_results(elapsed) if process_info[:terminated_by] == :interrupt
281
+ results = load_summary_results(process_info, elapsed)
282
+ return results if results
283
+
284
+ parse_results(process_info[:output])
285
+ end
286
+
287
+ def timeout_results(process_info, elapsed)
288
+ {
289
+ tests: 0,
290
+ assertions: 0,
291
+ failures: 0,
292
+ errors: 1,
293
+ duration: elapsed,
294
+ success: false,
295
+ error: "Timed out after #{process_info[:timeout]} seconds"
296
+ }
297
+ end
298
+
299
+ def interrupted_results(elapsed)
300
+ {
301
+ tests: 0,
302
+ assertions: 0,
303
+ failures: 0,
304
+ errors: 1,
305
+ duration: elapsed,
306
+ success: false,
307
+ error: "Interrupted before completion"
308
+ }
309
+ end
310
+
311
+ def load_summary_results(process_info, elapsed)
312
+ package = process_info[:package]
313
+ reports_dir = Atoms::ReportPathResolver.report_directory(
314
+ package["path"],
315
+ report_root: process_info[:report_root],
316
+ package_name: package["name"]
317
+ )
318
+ summary_file = reports_dir ? File.join(reports_dir, "summary.json") : nil
319
+ return nil unless summary_file && File.exist?(summary_file)
320
+
321
+ json_data = JSON.parse(File.read(summary_file))
322
+ results = {
323
+ tests: json_data["total"] || 0,
324
+ assertions: json_data["assertions"] || 0,
325
+ failures: json_data["failed"] || 0,
326
+ errors: json_data["errors"] || 0,
327
+ duration: json_data["duration"] || elapsed,
328
+ success: json_data["success"] || false
329
+ }
330
+
331
+ if results[:assertions] == 0
332
+ report_file = File.join(reports_dir, "report.json")
333
+ if File.exist?(report_file)
334
+ report_data = JSON.parse(File.read(report_file))
335
+ results[:assertions] = report_data.dig("result", "assertions") || 0
336
+ end
337
+ end
338
+
339
+ results
340
+ rescue JSON::ParserError
341
+ nil
226
342
  end
227
343
 
228
344
  def parse_progress(process_info, chunk)
@@ -8,9 +8,10 @@ module Ace
8
8
  class ResultAggregator
9
9
  attr_reader :packages
10
10
 
11
- def initialize(packages, report_root: nil)
11
+ def initialize(packages, report_root: nil, runtime_results: {})
12
12
  @packages = packages
13
13
  @report_root = report_root
14
+ @runtime_results = runtime_results || {}
14
15
  end
15
16
 
16
17
  def aggregate
@@ -90,8 +91,7 @@ module Ace
90
91
  }
91
92
  end
92
93
  else
93
- # No summary file means tests didn't complete or save
94
- {
94
+ runtime_result(package) || {
95
95
  package: package["name"],
96
96
  path: package["path"],
97
97
  report_root: @report_root,
@@ -119,6 +119,32 @@ module Ace
119
119
  end
120
120
  end
121
121
 
122
+ def runtime_result(package)
123
+ status = @runtime_results[package["name"]]
124
+ return nil unless status && status[:completed]
125
+
126
+ results = status[:results] || {}
127
+ total = results[:tests] || 0
128
+ failures = results[:failures] || 0
129
+ errors = results[:errors] || 0
130
+ skipped = results[:skipped] || 0
131
+
132
+ {
133
+ package: package["name"],
134
+ path: package["path"],
135
+ report_root: @report_root,
136
+ success: status[:success],
137
+ error: results[:error],
138
+ total: total,
139
+ passed: total - failures - errors - skipped,
140
+ failed: failures,
141
+ errors: errors,
142
+ skipped: skipped,
143
+ duration: results[:duration] || status[:elapsed] || 0,
144
+ assertions: results[:assertions] || 0
145
+ }
146
+ end
147
+
122
148
  def generate_report(summary)
123
149
  report = []
124
150
  report << "# ACE Test Suite Report"
@@ -86,6 +86,7 @@ module Ace
86
86
 
87
87
  line = "#{icon} #{elapsed} #{name} #{tests_col} #{asserts_col} #{fail_col}"
88
88
  line += " #{skipped} skip" if skipped > 0
89
+ line += " timeout" if status[:timed_out]
89
90
 
90
91
  puts line
91
92
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module TestRunner
5
- VERSION = '0.18.1'
5
+ VERSION = '0.19.1'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-test-runner
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.18.1
4
+ version: 0.19.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
10
+ date: 2026-03-29 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli