ace-test-runner 0.18.1 → 0.25.5

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.
@@ -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, exit_status)
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
@@ -200,8 +182,8 @@ module Ace
200
182
  def build_command(package, options)
201
183
  cmd_parts = ["ace-test"]
202
184
 
203
- # Always run in single batch for suite execution
204
- # This avoids nested group headers and improves performance
185
+ # Suite package execution intentionally bypasses grouped mode so each
186
+ # package runs its full target scope as one batch under suite orchestration.
205
187
  cmd_parts << "--run-in-single-batch"
206
188
 
207
189
  # Add format (use progress if compact is specified since ace-test doesn't have compact format)
@@ -219,10 +201,176 @@ 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
+ target = options["target"]
205
+ cmd_parts << target if target && !target.to_s.empty?
206
+
207
+ cmd_parts
208
+ end
209
+
210
+ def now
211
+ @clock.call
212
+ end
213
+
214
+ def enforce_timeout(process_info)
215
+ timeout = process_info[:timeout]
216
+ return unless timeout
217
+
218
+ elapsed = now - process_info[:start_time]
219
+
220
+ if !process_info[:timeout_triggered] && elapsed > timeout
221
+ process_info[:timeout_triggered] = true
222
+ process_info[:terminate_at] = now + @termination_grace_period
223
+ terminate_process_group(process_info, signal: "TERM", reason: :timeout)
224
+ elsif process_info[:timeout_triggered] && process_info[:thread].alive? && process_info[:terminate_at] && now >= process_info[:terminate_at]
225
+ terminate_process_group(process_info, signal: "KILL", reason: :timeout)
226
+ process_info[:terminate_at] = nil
227
+ end
228
+ end
229
+
230
+ def terminate_process_group(process_info, signal:, reason:)
231
+ process_info[:terminated_by] = reason
232
+ process_info[:terminating] = true
233
+ Process.kill(signal, -process_info[:pgid])
234
+ rescue Errno::ESRCH, Errno::EPERM
235
+ nil
236
+ end
237
+
238
+ def drain_stream(io, buffer)
239
+ return nil unless io && !io.closed?
240
+
241
+ chunk = +""
242
+ loop do
243
+ ready = IO.select([io], nil, nil, 0)
244
+ break unless ready
245
+
246
+ chunk << io.read_nonblock(4096)
247
+ end
248
+
249
+ buffer << chunk unless chunk.empty?
250
+ chunk
251
+ rescue IO::WaitReadable, EOFError
252
+ buffer << chunk unless chunk.empty?
253
+ chunk
254
+ rescue IOError
255
+ nil
256
+ end
257
+
258
+ def collect_remaining_output(process_info)
259
+ process_info[:output] << safe_read(process_info[:stdout])
260
+ process_info[:stderr_output] << safe_read(process_info[:stderr])
261
+ end
262
+
263
+ def safe_read(io)
264
+ return "" unless io && !io.closed?
265
+
266
+ io.read
267
+ rescue StandardError
268
+ ""
269
+ end
270
+
271
+ def close_streams(process_info)
272
+ %i[stdout stderr stdin].each do |stream|
273
+ begin
274
+ process_info[stream]&.close
275
+ rescue StandardError
276
+ nil
277
+ end
278
+ end
279
+ end
280
+
281
+ def build_results(process_info, elapsed, exit_status)
282
+ return timeout_results(process_info, elapsed) if process_info[:timeout_triggered]
283
+ return interrupted_results(elapsed) if process_info[:terminated_by] == :interrupt
284
+ fresh_summary = load_summary_results(process_info, elapsed)
285
+ return fresh_summary if exit_status == 0 && fresh_summary
286
+ return failed_process_results(process_info, elapsed, exit_status, fresh_summary) if exit_status != 0
287
+
288
+ parse_results(process_info[:output])
289
+ end
290
+
291
+ def timeout_results(process_info, elapsed)
292
+ {
293
+ tests: 0,
294
+ assertions: 0,
295
+ failures: 0,
296
+ errors: 1,
297
+ duration: elapsed,
298
+ success: false,
299
+ error: "Timed out after #{process_info[:timeout]} seconds"
300
+ }
301
+ end
302
+
303
+ def interrupted_results(elapsed)
304
+ {
305
+ tests: 0,
306
+ assertions: 0,
307
+ failures: 0,
308
+ errors: 1,
309
+ duration: elapsed,
310
+ success: false,
311
+ error: "Interrupted before completion"
312
+ }
313
+ end
314
+
315
+ def failed_process_results(process_info, elapsed, exit_status, fresh_summary)
316
+ return fresh_summary if fresh_summary && fresh_summary[:success] == false
317
+
318
+ parsed = parse_results([process_info[:output], process_info[:stderr_output]].join("\n"))
319
+ parsed[:duration] ||= elapsed
320
+ parsed[:success] = false
321
+ parsed[:error] ||= failure_message_for(process_info, exit_status)
322
+ parsed[:errors] = 1 if parsed[:failures].to_i == 0 && parsed[:errors].to_i == 0
323
+ parsed
324
+ end
325
+
326
+ def failure_message_for(process_info, exit_status)
327
+ stderr = process_info[:stderr_output].to_s.strip
328
+ stdout = process_info[:output].to_s.strip
329
+ message = stderr.empty? ? stdout : stderr
330
+ return message unless message.empty?
331
+
332
+ "ace-test exited with status #{exit_status}"
333
+ end
334
+
335
+ def load_summary_results(process_info, elapsed)
336
+ package = process_info[:package]
337
+ reports_dir = Atoms::ReportPathResolver.report_directory(
338
+ package["path"],
339
+ report_root: process_info[:report_root],
340
+ package_name: package["name"]
341
+ )
342
+ summary_file = reports_dir ? File.join(reports_dir, "summary.json") : nil
343
+ return nil unless summary_file && File.exist?(summary_file)
344
+ return nil unless summary_fresh_for_run?(summary_file, process_info[:start_time])
345
+
346
+ json_data = JSON.parse(File.read(summary_file))
347
+ results = {
348
+ tests: json_data["total"] || 0,
349
+ assertions: json_data["assertions"] || 0,
350
+ failures: json_data["failed"] || 0,
351
+ errors: json_data["errors"] || 0,
352
+ skipped: json_data["skipped"] || 0,
353
+ duration: json_data["duration"] || elapsed,
354
+ success: json_data["success"] || false
355
+ }
356
+
357
+ if results[:assertions] == 0
358
+ report_file = File.join(reports_dir, "report.json")
359
+ if File.exist?(report_file)
360
+ report_data = JSON.parse(File.read(report_file))
361
+ results[:assertions] = report_data.dig("result", "assertions") || 0
362
+ end
363
+ end
364
+
365
+ results
366
+ rescue JSON::ParserError
367
+ nil
368
+ end
369
+
370
+ def summary_fresh_for_run?(summary_file, start_time)
371
+ File.mtime(summary_file) >= (start_time - 0.001)
372
+ rescue StandardError
373
+ false
226
374
  end
227
375
 
228
376
  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
@@ -46,6 +47,9 @@ module Ace
46
47
 
47
48
  def collect_results
48
49
  @packages.map do |package|
50
+ runtime = runtime_result(package)
51
+ next runtime if runtime_result_overrides_summary?(package, runtime)
52
+
49
53
  reports_dir = Atoms::ReportPathResolver.report_directory(
50
54
  package["path"],
51
55
  report_root: @report_root,
@@ -90,8 +94,7 @@ module Ace
90
94
  }
91
95
  end
92
96
  else
93
- # No summary file means tests didn't complete or save
94
- {
97
+ runtime || {
95
98
  package: package["name"],
96
99
  path: package["path"],
97
100
  report_root: @report_root,
@@ -106,6 +109,13 @@ module Ace
106
109
  end
107
110
  end
108
111
 
112
+ def runtime_result_overrides_summary?(package, runtime)
113
+ return false unless runtime
114
+
115
+ status = @runtime_results[package["name"]] || {}
116
+ status[:timed_out] || status[:interrupted] || runtime[:success] == false
117
+ end
118
+
109
119
  def collect_failed_packages(results)
110
120
  results.select { |r| !r[:success] }.map do |result|
111
121
  {
@@ -119,6 +129,32 @@ module Ace
119
129
  end
120
130
  end
121
131
 
132
+ def runtime_result(package)
133
+ status = @runtime_results[package["name"]]
134
+ return nil unless status && status[:completed]
135
+
136
+ results = status[:results] || {}
137
+ total = results[:tests] || 0
138
+ failures = results[:failures] || 0
139
+ errors = results[:errors] || 0
140
+ skipped = results[:skipped] || 0
141
+
142
+ {
143
+ package: package["name"],
144
+ path: package["path"],
145
+ report_root: @report_root,
146
+ success: status[:success],
147
+ error: results[:error],
148
+ total: total,
149
+ passed: total - failures - errors - skipped,
150
+ failed: failures,
151
+ errors: errors,
152
+ skipped: skipped,
153
+ duration: results[:duration] || status[:elapsed] || 0,
154
+ assertions: results[:assertions] || 0
155
+ }
156
+ end
157
+
122
158
  def generate_report(summary)
123
159
  report = []
124
160
  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.25.5'
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.25.5
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-04-20 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli
@@ -189,9 +189,9 @@ files:
189
189
  - lib/ace/test_runner/formatters/progress_formatter.rb
190
190
  - lib/ace/test_runner/models/test_configuration.rb
191
191
  - lib/ace/test_runner/models/test_failure.rb
192
- - lib/ace/test_runner/models/test_group.rb
193
192
  - lib/ace/test_runner/models/test_report.rb
194
193
  - lib/ace/test_runner/models/test_result.rb
194
+ - lib/ace/test_runner/models/test_target.rb
195
195
  - lib/ace/test_runner/molecules/cli_argument_parser.rb
196
196
  - lib/ace/test_runner/molecules/config_loader.rb
197
197
  - lib/ace/test_runner/molecules/deprecation_fixer.rb
@@ -206,7 +206,7 @@ files:
206
206
  - lib/ace/test_runner/molecules/test_executor.rb
207
207
  - lib/ace/test_runner/organisms/agent_reporter.rb
208
208
  - lib/ace/test_runner/organisms/report_generator.rb
209
- - lib/ace/test_runner/organisms/sequential_group_executor.rb
209
+ - lib/ace/test_runner/organisms/sequential_target_executor.rb
210
210
  - lib/ace/test_runner/organisms/test_orchestrator.rb
211
211
  - lib/ace/test_runner/rake_task.rb
212
212
  - lib/ace/test_runner/suite.rb