tryouts 3.3.1 → 3.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: adb9f46213aee10ed4b9d2a1b9b8d5c9e385ea23f996445debc7458b43351512
4
- data.tar.gz: 8d6abfed42e73bb340e13a62991b4855b8c0c18efa3092b4e3244a822a1612c1
3
+ metadata.gz: 6b0f0a0ca7de83a58c4e822d127a963bb1fb08523d9146b24c8b33f8a2c12ff2
4
+ data.tar.gz: 2f4e61611e23176da42cc9bbf82a92a2cc1e9d6049943fd4d4827dcd2b34c84f
5
5
  SHA512:
6
- metadata.gz: 571410ed483879bd035b14c053a5c3496d51ad2248cff2534cf40fc06a72ece1aea13a7e61ae2c29b1e1f1768f19d3c5ede8c4e4a30a977c29aacb502c616988
7
- data.tar.gz: 6b5186131242edf826ba44303daaee716ce1ec364683e521df5224818d796067209a9a0ae66cacc85d15796cfee92df2349372fc5df762ced52b8753e1196c0e
6
+ metadata.gz: 1505d26c47b4fb3fb75675425e6aaac3e1ba81b6f39ac8ae47622ae43acd0c19a13a16e82486663de0201f21bf7b997281744fc6d69cae6116e67065803b4a39
7
+ data.tar.gz: ca57f3c38348023c57f5e382fe8b45e29877393fae155a718c921550b4a2200b0fe37c1c31be4f41dcbc934527d8c6b53e74fbe88fcc574c53cbc3d765bb3534
data/README.md CHANGED
@@ -1,17 +1,21 @@
1
- # Tryouts v3.1
1
+ # Tryouts v3
2
2
 
3
3
  **Ruby tests that read like documentation.**
4
4
 
5
5
  A modern test framework for Ruby that uses comments to define expectations. Tryouts are meant to double as documentation, so the Ruby code should be plain and reminiscent of real code.
6
6
 
7
+ > [!NOTE]
8
+ > **Agent-Optimized Output**: Tryouts includes specialized output modes for LLM consumption with `--agent` flag, providing structured, token-efficient test results that are 60-80% smaller than traditional output while preserving debugging context.
9
+
7
10
  > [!WARNING]
8
- > Version 3.0+ uses Ruby's Prism parser and pattern matching, requiring Ruby 3.4+
11
+ > Version 3.0+ uses Ruby's Prism parser and pattern matching, requiring Ruby 3.2+
9
12
 
10
13
  ## Key Features
11
14
 
12
15
  - **Documentation-style tests** using comment-based expectations (`#=>`)
13
16
  - **Great expectation syntax** for more expressive assertions (`#==>` for true, `#=/=>` for false, `#=:>` for class/module)
14
17
  - **Framework integration** write with tryouts syntax, run with RSpec or Minitest
18
+ - **Agent-optimized output** structured, token-efficient output for LLM consumption
15
19
  - **Enhanced error reporting** with line numbers and context
16
20
 
17
21
  ## Installation
@@ -117,6 +121,13 @@ try -v # verbose (includes source code and return values)
117
121
  try -q # quiet mode
118
122
  try -f # show failures only
119
123
  try -D # debug mode
124
+
125
+ # Agent-optimized output for LLMs
126
+ try --agent # structured, token-efficient output
127
+ try --agent --agent-focus summary # show only counts and problem files
128
+ try --agent --agent-focus first-failure # show first failure per file
129
+ try --agent --agent-focus critical # show only errors/exceptions
130
+ try --agent --agent-limit 1000 # limit output to 1000 tokens
120
131
  ```
121
132
 
122
133
  ### Exit Codes
@@ -127,14 +138,14 @@ try -D # debug mode
127
138
 
128
139
  ## Requirements
129
140
 
130
- - **Ruby >= 3.2+** (for Prism parser and pattern matching)
141
+ - **Ruby >= 3.2** (for Prism parser and pattern matching)
131
142
  - **RSpec** or **Minitest** (optional, for framework integration)
132
143
 
133
144
  ## Modern Architecture (v3+)
134
145
 
135
146
  ### Core Components
136
147
 
137
- - **Prism Parser**: Inhouse Ruby parsing with pattern matching for line classification
148
+ - **Prism Parser**: Native Ruby parsing with pattern matching for line classification
138
149
  - **Data Structures**: Immutable `Data.define` classes for test representation
139
150
  - **Framework Translators**: Convert tryouts to RSpec/Minitest format
140
151
  - **CLI**: Modern command-line interface with framework selection
@@ -0,0 +1,450 @@
1
+ # lib/tryouts/cli/formatters/agent.rb
2
+
3
+ require_relative 'token_budget'
4
+
5
+ class Tryouts
6
+ class CLI
7
+ # Agent-optimized formatter designed for LLM context management
8
+ # Features:
9
+ # - Token budget awareness
10
+ # - Structured YAML-like output
11
+ # - No redundant file paths
12
+ # - Smart truncation
13
+ # - Hierarchical organization
14
+ class AgentFormatter
15
+ include FormatterInterface
16
+
17
+ def initialize(options = {})
18
+ super
19
+ @budget = TokenBudget.new(options[:agent_limit] || TokenBudget::DEFAULT_LIMIT)
20
+ @focus_mode = options[:agent_focus] || :failures
21
+ @collected_files = []
22
+ @current_file_data = nil
23
+ @total_stats = { files: 0, tests: 0, failures: 0, errors: 0, elapsed: 0 }
24
+ @output_rendered = false
25
+
26
+ # No colors in agent mode for cleaner parsing
27
+ @use_colors = false
28
+ end
29
+
30
+ # Phase-level output - collect data, don't output immediately
31
+ def phase_header(message, file_count: nil)
32
+ # Store file count for later use, but only store actual file count
33
+ if file_count && message.include?("FILES")
34
+ @total_stats[:files] = file_count
35
+ end
36
+ end
37
+
38
+ # File-level operations - start collecting file data
39
+ def file_start(file_path, context_info: {})
40
+ @current_file_data = {
41
+ path: relative_path(file_path),
42
+ tests: 0,
43
+ failures: [],
44
+ errors: [],
45
+ passed: 0
46
+ }
47
+ end
48
+
49
+ def file_end(file_path, context_info: {})
50
+ # Finalize current file data
51
+ if @current_file_data
52
+ @collected_files << @current_file_data
53
+ @current_file_data = nil
54
+ end
55
+ # REMOVED: No longer attempts to render here to avoid premature output
56
+ end
57
+
58
+ def file_parsed(_file_path, test_count:, setup_present: false, teardown_present: false)
59
+ @current_file_data[:tests] = test_count if @current_file_data
60
+ @total_stats[:tests] += test_count
61
+ end
62
+
63
+ def file_result(_file_path, total_tests:, failed_count:, error_count:, elapsed_time: nil)
64
+ # Always update global totals
65
+ @total_stats[:failures] += failed_count
66
+ @total_stats[:errors] += error_count
67
+ @total_stats[:elapsed] += elapsed_time if elapsed_time
68
+
69
+ # Update per-file data when available
70
+ if @current_file_data
71
+ @current_file_data[:passed] = total_tests - failed_count - error_count
72
+ end
73
+ end
74
+
75
+
76
+ # Test-level operations - collect failure data
77
+ def test_result(result_packet)
78
+ return unless @current_file_data
79
+
80
+ # For summary mode, we still need to collect failures for counting, just don't build detailed data
81
+ if result_packet.failed? || result_packet.error?
82
+ if @focus_mode == :summary
83
+ # Just track counts for summary
84
+ if result_packet.error?
85
+ @current_file_data[:errors] << { basic: true }
86
+ else
87
+ @current_file_data[:failures] << { basic: true }
88
+ end
89
+ else
90
+ # Build detailed failure data for other modes
91
+ failure_data = build_failure_data(result_packet)
92
+
93
+ if result_packet.error?
94
+ @current_file_data[:errors] << failure_data
95
+ else
96
+ @current_file_data[:failures] << failure_data
97
+ end
98
+
99
+ # Mark truncation for first-failure mode (handle limiting in render phase)
100
+ if (@focus_mode == :first_failure || @focus_mode == :'first-failure') &&
101
+ (@current_file_data[:failures].size + @current_file_data[:errors].size) > 1
102
+ @current_file_data[:truncated] = true
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ # Summary operations - reliable trigger for rendering
109
+ def batch_summary(failure_collector)
110
+ # This becomes the single, reliable trigger for rendering
111
+ grand_total(
112
+ total_tests: @total_stats[:tests],
113
+ failed_count: @collected_files.sum { |f| f[:failures].size },
114
+ error_count: @collected_files.sum { |f| f[:errors].size },
115
+ successful_files: @collected_files.size - @collected_files.count { |f| f[:failures].any? || f[:errors].any? },
116
+ total_files: @collected_files.size,
117
+ elapsed_time: @total_stats[:elapsed]
118
+ ) unless @output_rendered
119
+ end
120
+
121
+ def grand_total(total_tests:, failed_count:, error_count:, successful_files:, total_files:, elapsed_time:)
122
+ return if @output_rendered # Prevent double rendering
123
+
124
+ @total_stats.merge!(
125
+ tests: total_tests,
126
+ failures: failed_count,
127
+ errors: error_count,
128
+ successful_files: successful_files,
129
+ total_files: total_files,
130
+ elapsed: elapsed_time
131
+ )
132
+
133
+ # Now render all collected data
134
+ render_agent_output
135
+ @output_rendered = true
136
+ end
137
+
138
+ # Override live status - not needed for agent mode
139
+ def live_status_capabilities
140
+ {
141
+ supports_coordination: false,
142
+ output_frequency: :none,
143
+ requires_tty: false
144
+ }
145
+ end
146
+
147
+ private
148
+
149
+ def build_failure_data(result_packet)
150
+ test_case = result_packet.test_case
151
+
152
+ failure_data = {
153
+ line: (test_case.first_expectation_line || test_case.line_range&.first || 0) + 1,
154
+ test: test_case.description.to_s.empty? ? 'unnamed test' : test_case.description.to_s
155
+ }
156
+
157
+ case result_packet.status
158
+ when :error
159
+ error = result_packet.error
160
+ failure_data[:error] = error ? "#{error.class.name}: #{error.message}" : 'unknown error'
161
+ when :failed
162
+ if result_packet.expected_results.any? && result_packet.actual_results.any?
163
+ expected = @budget.smart_truncate(result_packet.first_expected, max_tokens: 25)
164
+ actual = @budget.smart_truncate(result_packet.first_actual, max_tokens: 25)
165
+ failure_data[:expected] = expected
166
+ failure_data[:got] = actual
167
+
168
+ # Add diff for strings if budget allows
169
+ if result_packet.first_expected.is_a?(String) &&
170
+ result_packet.first_actual.is_a?(String) &&
171
+ @budget.has_budget?
172
+ failure_data[:diff] = generate_simple_diff(result_packet.first_expected, result_packet.first_actual)
173
+ end
174
+ else
175
+ failure_data[:reason] = 'test failed'
176
+ end
177
+ end
178
+
179
+ failure_data
180
+ end
181
+
182
+ def generate_simple_diff(expected, actual)
183
+ return nil unless @budget.remaining > 100 # Only if we have decent budget left
184
+
185
+ # Simple line-by-line diff
186
+ exp_lines = expected.split("\n")
187
+ act_lines = actual.split("\n")
188
+
189
+ diff_lines = []
190
+ diff_lines << "- #{act_lines.first}" if act_lines.any?
191
+ diff_lines << "+ #{exp_lines.first}" if exp_lines.any?
192
+
193
+ diff_result = diff_lines.join("\n")
194
+ return @budget.fit_text(diff_result) if @budget.would_exceed?(diff_result)
195
+ diff_result
196
+ end
197
+
198
+ def render_agent_output
199
+ case @focus_mode
200
+ when :summary
201
+ render_summary_only
202
+ when :critical
203
+ render_critical_only
204
+ else
205
+ render_full_structured
206
+ end
207
+ end
208
+
209
+ def render_summary_only
210
+ output = []
211
+
212
+ # Count failures manually from collected file data (same as other render methods)
213
+ failed_count = @collected_files.sum { |f| f[:failures].size }
214
+ error_count = @collected_files.sum { |f| f[:errors].size }
215
+ issues_count = failed_count + error_count
216
+ passed_count = [@total_stats[:tests] - issues_count, 0].max
217
+
218
+ if issues_count > 0
219
+ status = "FAIL: #{issues_count}/#{@total_stats[:tests]} tests"
220
+ details = []
221
+ details << "#{failed_count} failed" if failed_count > 0
222
+ details << "#{error_count} errors" if error_count > 0
223
+ status += " (#{details.join(', ')}, #{passed_count} passed)"
224
+ else
225
+ status = "PASS: #{@total_stats[:tests]} tests passed"
226
+ end
227
+
228
+ status += " (#{format_time(@total_stats[:elapsed])})" if @total_stats[:elapsed]
229
+
230
+ output << status
231
+
232
+ # Show which files had failures
233
+ files_with_issues = @collected_files.select { |f| f[:failures].any? || f[:errors].any? }
234
+ if files_with_issues.any?
235
+ output << ""
236
+ output << "Files with issues:"
237
+ files_with_issues.each do |file_data|
238
+ issue_count = file_data[:failures].size + file_data[:errors].size
239
+ output << " #{file_data[:path]}: #{issue_count} issue#{'s' if issue_count != 1}"
240
+ end
241
+ end
242
+
243
+ puts output.join("\n")
244
+ end
245
+
246
+ def render_critical_only
247
+ # Only show errors (exceptions), skip assertion failures
248
+ critical_files = @collected_files.select { |f| f[:errors].any? }
249
+
250
+ if critical_files.empty?
251
+ puts "No critical errors found"
252
+ return
253
+ end
254
+
255
+ output = []
256
+ output << "CRITICAL: #{critical_files.size} file#{'s' if critical_files.size != 1} with errors"
257
+ output << ""
258
+
259
+ critical_files.each do |file_data|
260
+ unless @budget.has_budget?
261
+ output << "... (truncated due to token limit)"
262
+ break
263
+ end
264
+
265
+ output << "#{file_data[:path]}:"
266
+
267
+ file_data[:errors].each do |error|
268
+ error_line = " L#{error[:line]}: #{error[:error]}"
269
+ if @budget.would_exceed?(error_line)
270
+ output << @budget.fit_text(error_line)
271
+ else
272
+ output << error_line
273
+ @budget.consume(error_line)
274
+ end
275
+ end
276
+
277
+ output << ""
278
+ end
279
+
280
+ puts output.join("\n")
281
+ end
282
+
283
+ def render_full_structured
284
+ output = []
285
+
286
+ # Header with overall stats
287
+ issues_count = @total_stats[:failures] + @total_stats[:errors]
288
+ passed_count = [@total_stats[:tests] - issues_count, 0].max
289
+
290
+ files_count = if @total_stats[:files].to_i > 0
291
+ @total_stats[:files]
292
+ else
293
+ @total_stats[:total_files] || @collected_files.size
294
+ end
295
+
296
+ if issues_count > 0
297
+ status_line = "FAIL: #{issues_count}/#{@total_stats[:tests]} tests (#{files_count} files, #{format_time(@total_stats[:elapsed])})"
298
+ else
299
+ status_line = "PASS: #{@total_stats[:tests]} tests (#{files_count} files, #{format_time(@total_stats[:elapsed])})"
300
+ end
301
+
302
+ # Always include status line
303
+ output << status_line
304
+ @budget.force_consume(status_line)
305
+
306
+ # Only show files with issues (unless focus is different)
307
+ files_to_show = case @focus_mode
308
+ when :failures, :first_failure
309
+ @collected_files.select { |f| f[:failures].any? || f[:errors].any? }
310
+ else
311
+ @collected_files.select { |f| f[:failures].any? || f[:errors].any? }
312
+ end
313
+
314
+ if files_to_show.any?
315
+ output << ""
316
+ @budget.consume("\n")
317
+
318
+ files_to_show.each do |file_data|
319
+ break unless @budget.has_budget?
320
+
321
+ file_section = render_file_section(file_data)
322
+ if @budget.would_exceed?(file_section)
323
+ # Try to fit what we can
324
+ truncated = @budget.fit_text(file_section, preserve_suffix: "\n ... (truncated)")
325
+ output << truncated if truncated.length > 20 # Only if meaningful content remains
326
+ break
327
+ else
328
+ output << file_section
329
+ @budget.consume(file_section)
330
+ end
331
+ end
332
+ end
333
+
334
+ # Final summary line
335
+ summary = "Summary: #{passed_count} passed, #{@total_stats[:failures]} failed"
336
+ summary += ", #{@total_stats[:errors]} errors" if @total_stats[:errors] > 0
337
+ summary += " in #{@total_stats[:files]} files"
338
+
339
+ output << ""
340
+ output << summary
341
+
342
+ puts output.join("\n")
343
+ end
344
+
345
+ def render_file_section(file_data)
346
+ lines = []
347
+
348
+ # File header
349
+ lines << "#{file_data[:path]}:"
350
+
351
+ # For first-failure mode, only show first error or failure
352
+ if @focus_mode == :first_failure || @focus_mode == :'first-failure'
353
+ shown_count = 0
354
+
355
+ # Show first error
356
+ if file_data[:errors].any? && shown_count == 0
357
+ error = file_data[:errors].first
358
+ lines << " L#{error[:line]}: #{error[:error]}"
359
+ lines << " Test: #{error[:test]}" if error[:test] != 'unnamed test'
360
+ shown_count += 1
361
+ end
362
+
363
+ # Show first failure if no error was shown
364
+ if file_data[:failures].any? && shown_count == 0
365
+ failure = file_data[:failures].first
366
+ line_parts = [" L#{failure[:line]}:"]
367
+
368
+ if failure[:expected] && failure[:got]
369
+ line_parts << "expected #{failure[:expected]}, got #{failure[:got]}"
370
+ elsif failure[:reason]
371
+ line_parts << failure[:reason]
372
+ end
373
+
374
+ lines << line_parts.join(' ')
375
+ lines << " Test: #{failure[:test]}" if failure[:test] != 'unnamed test'
376
+
377
+ # Add diff if available and budget allows
378
+ if failure[:diff] && @budget.remaining > 50
379
+ lines << " Diff:"
380
+ failure[:diff].split("\n").each { |diff_line| lines << " #{diff_line}" }
381
+ end
382
+ end
383
+
384
+ # Show truncation notice
385
+ total_issues = file_data[:errors].size + file_data[:failures].size
386
+ if total_issues > 1
387
+ lines << " ... (#{total_issues - 1} more failures not shown)"
388
+ end
389
+ else
390
+ # Normal mode - show all errors and failures
391
+ # Errors first (more critical)
392
+ file_data[:errors].each do |error|
393
+ next if error[:basic] # Skip basic entries from summary mode
394
+ lines << " L#{error[:line]}: #{error[:error]}"
395
+ lines << " Test: #{error[:test]}" if error[:test] != 'unnamed test'
396
+ end
397
+
398
+ # Then failures
399
+ file_data[:failures].each do |failure|
400
+ next if failure[:basic] # Skip basic entries from summary mode
401
+ line_parts = [" L#{failure[:line]}:"]
402
+
403
+ if failure[:expected] && failure[:got]
404
+ line_parts << "expected #{failure[:expected]}, got #{failure[:got]}"
405
+ elsif failure[:reason]
406
+ line_parts << failure[:reason]
407
+ end
408
+
409
+ lines << line_parts.join(' ')
410
+ lines << " Test: #{failure[:test]}" if failure[:test] != 'unnamed test'
411
+
412
+ # Add diff if available and budget allows
413
+ if failure[:diff] && @budget.remaining > 50
414
+ lines << " Diff:"
415
+ failure[:diff].split("\n").each { |diff_line| lines << " #{diff_line}" }
416
+ end
417
+ end
418
+
419
+ # Show truncation notice if applicable
420
+ if file_data[:truncated]
421
+ lines << " ... (more failures not shown)"
422
+ end
423
+ end
424
+
425
+ lines.join("\n")
426
+ end
427
+
428
+ def relative_path(file_path)
429
+ # Remove leading path components to save tokens
430
+ path = Pathname.new(file_path).relative_path_from(Pathname.pwd).to_s
431
+ # If relative path is longer, use just filename
432
+ path.include?('../') ? File.basename(file_path) : path
433
+ rescue
434
+ File.basename(file_path)
435
+ end
436
+
437
+ def format_time(seconds)
438
+ return '0ms' unless seconds
439
+
440
+ if seconds < 0.001
441
+ "#{(seconds * 1_000_000).round}μs"
442
+ elsif seconds < 1
443
+ "#{(seconds * 1000).round}ms"
444
+ else
445
+ "#{seconds.round(2)}s"
446
+ end
447
+ end
448
+ end
449
+ end
450
+ end
@@ -11,6 +11,7 @@ class Tryouts
11
11
  @show_debug = options.fetch(:debug, false)
12
12
  @show_trace = options.fetch(:trace, false)
13
13
  @show_passed = options.fetch(:show_passed, true)
14
+ @show_stack_traces = options.fetch(:stack_traces, false) || options.fetch(:debug, false)
14
15
  end
15
16
 
16
17
  # Phase-level output - minimal for compact mode
@@ -238,10 +239,10 @@ class Tryouts
238
239
  def error_message(message, backtrace: nil)
239
240
  @stderr.puts Console.color(:red, "ERROR: #{message}")
240
241
 
241
- return unless backtrace && @show_debug
242
+ return unless backtrace && @show_stack_traces
242
243
 
243
- backtrace.first(3).each do |line|
244
- @stderr.puts indent_text(line.chomp, 1)
244
+ Console.pretty_backtrace(backtrace, limit: 3).each do |line|
245
+ @stderr.puts indent_text(line, 1)
245
246
  end
246
247
  end
247
248
 
@@ -12,6 +12,11 @@ class Tryouts
12
12
  end
13
13
 
14
14
  def self.create_formatter(options = {})
15
+ # Check for agent mode first (takes precedence)
16
+ if options[:agent]
17
+ return AgentFormatter.new(options)
18
+ end
19
+
15
20
  # Map boolean flags to format symbols if format not explicitly set
16
21
  format = options[:format]&.to_sym || determine_format_from_flags(options)
17
22
 
@@ -10,6 +10,7 @@ class Tryouts
10
10
  super
11
11
  @show_errors = options.fetch(:show_errors, true)
12
12
  @show_final_summary = options.fetch(:show_final_summary, true)
13
+ @show_stack_traces = options.fetch(:stack_traces, false) || options.fetch(:debug, false)
13
14
  @current_file = nil
14
15
  end
15
16
 
@@ -80,10 +81,10 @@ class Tryouts
80
81
  @stderr.puts
81
82
  @stderr.puts Console.color(:red, "ERROR: #{message}")
82
83
 
83
- return unless backtrace && @show_debug
84
+ return unless backtrace && @show_stack_traces
84
85
 
85
- backtrace.first(3).each do |line|
86
- @stderr.puts " #{line.chomp}"
86
+ Console.pretty_backtrace(backtrace, limit: 3).each do |line|
87
+ @stderr.puts " #{line}"
87
88
  end
88
89
  end
89
90