rspec-agents 0.1.0 → 0.1.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/bin/rspec-agents +24 -24
  3. data/lib/rspec/agents/backtrace_helper.rb +43 -0
  4. data/lib/rspec/agents/cli.rb +24 -13
  5. data/lib/rspec/agents/dsl/runner_factory.rb +6 -14
  6. data/lib/rspec/agents/dsl/scenario_set_dsl.rb +8 -0
  7. data/lib/rspec/agents/dsl/test_context.rb +13 -3
  8. data/lib/rspec/agents/dsl.rb +5 -4
  9. data/lib/rspec/agents/event_bus.rb +37 -4
  10. data/lib/rspec/agents/observers/base.rb +1 -1
  11. data/lib/rspec/agents/observers/parallel_terminal_observer.rb +1 -1
  12. data/lib/rspec/agents/parallel/ui/interactive_ui.rb +5 -0
  13. data/lib/rspec/agents/parallel/ui/interleaved_ui.rb +5 -0
  14. data/lib/rspec/agents/runners/headless_runner.rb +6 -30
  15. data/lib/rspec/agents/runners/parallel_terminal_runner.rb +28 -4
  16. data/lib/rspec/agents/runners/run_data_uploader.rb +71 -0
  17. data/lib/rspec/agents/runners/terminal_runner.rb +36 -15
  18. data/lib/rspec/agents/runners/user_simulator.rb +13 -16
  19. data/lib/rspec/agents/serialization/base_renderer.rb +100 -0
  20. data/lib/rspec/agents/serialization/conversation_renderer.rb +41 -38
  21. data/lib/rspec/agents/serialization/extension.rb +38 -13
  22. data/lib/rspec/agents/serialization/extensions/copy_example_json_extension.rb +53 -0
  23. data/lib/rspec/agents/serialization/extensions/copy_example_json_templates/_copy_example_json.js +31 -0
  24. data/lib/rspec/agents/serialization/extensions/core_extension.rb +18 -2
  25. data/lib/rspec/agents/serialization/ir.rb +403 -0
  26. data/lib/rspec/agents/serialization/run_data_builder.rb +7 -2
  27. data/lib/rspec/agents/serialization/templates/_conversation_fragment.html.haml +1 -1
  28. data/lib/rspec/agents/serialization/templates/_scripts.js +0 -25
  29. data/lib/rspec/agents/serialization/templates/_styles.css +125 -0
  30. data/lib/rspec/agents/serialization/templates/conversation_document.html.haml +6 -5
  31. data/lib/rspec/agents/serialization/test_suite_renderer.rb +9 -56
  32. data/lib/rspec/agents/serialization.rb +52 -19
  33. data/lib/rspec/agents/spec_executor.rb +11 -25
  34. data/lib/rspec/agents/turn_executor.rb +1 -0
  35. data/lib/rspec/agents/version.rb +1 -1
  36. data/lib/rspec/agents.rb +3 -1
  37. metadata +11 -6
  38. data/lib/rspec/agents/isolated_event_bus.rb +0 -86
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 123d6a20179dda36dcfd3020a74c12170dde1a512ab4d8a28d6499225793a8b0
4
- data.tar.gz: d985272c0f2cb2492880a47fb9818de9f3ada465daf8da4308a0691e1e714359
3
+ metadata.gz: ae027e89bf78dc8f780f04c71c237d97d986cd79943c9f975cecca638b778368
4
+ data.tar.gz: 3e4ce43b5c859fe56157b3aeadfdf4e1cb2d70570196a9cbd0175377e68d2c1b
5
5
  SHA512:
6
- metadata.gz: 0c6a8dcef1c0aad57ed56a11d4d672785cd843dc721ce6dc5f2d9a94ff7a0d9ad7a2e9c6646397f63be95947954e3c7d876cc266af53b7fc3064e0de081857db
7
- data.tar.gz: f1f8f75d7a3f80a281791d7098eefb633e276f8ccf7e854b786423801e172ceaabd276c6d80649bf87667e81a25cbc2b791adf77ccd62a0bb431e4b0467af5e5
6
+ metadata.gz: 2edb7ff68a8037905032d261df6fa21889597ac83ac1edd8024241bed3d8b954db1ac16a280b58fb09c96b561470e78fcc72bf2d06b0f766765b5bbadbc140ff
7
+ data.tar.gz: b1dae96b52dcc29b198719f86d378aea0f9b8cc84e97de995ffc9aa99363e17948c865c509df00c56034b3389b3dbb946974d77ae6627f0ba646180f71280a50
data/bin/rspec-agents CHANGED
@@ -1,24 +1,24 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- # Unified CLI for rspec-agents
5
- #
6
- # Usage:
7
- # rspec-agents [run] [options] [paths...] # Single-process (default)
8
- # rspec-agents parallel [options] [paths...] # Parallel with workers
9
- # rspec-agents render <json_file> [options] # Render HTML from JSON
10
- # rspec-agents worker # Internal: worker mode
11
-
12
- # macOS fork safety - must be set before any Objective-C code loads
13
- ENV["OBJC_DISABLE_INITIALIZE_FORK_SAFETY"] = "YES"
14
-
15
- # Sync output streams for worker mode
16
- $stdout.sync = true
17
- $stderr.sync = true
18
-
19
- require "rspec/agents"
20
- require "rspec/agents/cli"
21
-
22
- # Run CLI
23
- exit_code = RSpec::Agents::CLI.run(ARGV)
24
- exit(exit_code || 0)
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Unified CLI for rspec-agents
5
+ #
6
+ # Usage:
7
+ # rspec-agents [run] [options] [paths...] # Single-process (default)
8
+ # rspec-agents parallel [options] [paths...] # Parallel with workers
9
+ # rspec-agents render <json_file> [options] # Render HTML from JSON
10
+ # rspec-agents worker # Internal: worker mode
11
+
12
+ # macOS fork safety - must be set before any Objective-C code loads
13
+ ENV["OBJC_DISABLE_INITIALIZE_FORK_SAFETY"] = "YES"
14
+
15
+ # Sync output streams for worker mode
16
+ $stdout.sync = true
17
+ $stderr.sync = true
18
+
19
+ require "rspec/agents"
20
+ require "rspec/agents/cli"
21
+
22
+ # Run CLI
23
+ exit_code = RSpec::Agents::CLI.run(ARGV)
24
+ exit(exit_code || 0)
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Agents
5
+ # Shared backtrace extraction and filtering for spec executors.
6
+ # Included by SpecExecutor (sequential) and HeadlessRunner (parallel worker).
7
+ module BacktraceHelper
8
+ MAX_BACKTRACE_LINES = 10
9
+
10
+ def extract_failure_message(notification)
11
+ if notification.respond_to?(:exception) && notification.exception
12
+ notification.exception.message
13
+ elsif notification.example.execution_result.exception
14
+ notification.example.execution_result.exception.message
15
+ end
16
+ end
17
+
18
+ def extract_backtrace(notification)
19
+ backtrace = if notification.respond_to?(:formatted_backtrace)
20
+ notification.formatted_backtrace
21
+ elsif notification.example.execution_result.exception
22
+ notification.example.execution_result.exception.backtrace
23
+ end
24
+ filter_backtrace(backtrace)
25
+ end
26
+
27
+ def filter_backtrace(backtrace)
28
+ return nil unless backtrace
29
+
30
+ app_path = File.expand_path(Dir.getwd)
31
+ filtered = backtrace.select { |line|
32
+ # Extract the file path portion (before the first colon, e.g. "/path/to/file.rb:42")
33
+ file_path = line.split(":").first
34
+ expanded = File.expand_path(file_path) rescue file_path
35
+ expanded.start_with?(app_path)
36
+ }.first(MAX_BACKTRACE_LINES)
37
+
38
+ # If nothing matched (e.g. formatted_backtrace already filtered), use as-is
39
+ filtered.empty? ? backtrace.first(MAX_BACKTRACE_LINES) : filtered
40
+ end
41
+ end
42
+ end
43
+ end
@@ -91,16 +91,17 @@ module RSpec
91
91
  options = parse_single_options(args)
92
92
 
93
93
  runner = Runners::TerminalRunner.new(
94
- output: $stdout,
95
- color: options[:color],
96
- json_path: options[:json_path],
97
- html_path: options[:html_path]
94
+ output: $stdout,
95
+ color: options[:color],
96
+ json_path: options[:json_path],
97
+ html_path: options[:html_path],
98
+ upload_url: options[:upload_url]
98
99
  )
99
100
  runner.run(options[:paths])
100
101
  end
101
102
 
102
103
  def parse_single_options(args)
103
- options = { paths: [], color: nil, json_path: nil, html_path: nil }
104
+ options = { paths: [], color: nil, json_path: nil, html_path: nil, upload_url: nil }
104
105
 
105
106
  parser = OptionParser.new do |opts|
106
107
  opts.banner = "Usage: rspec-agents [run] [options] [paths...]"
@@ -126,6 +127,10 @@ module RSpec
126
127
  options[:html_path] = path
127
128
  end
128
129
 
130
+ opts.on("--upload [URL]", "Upload run data to agents-studio (default: http://localhost:9292)") do |url|
131
+ options[:upload_url] = url || "http://localhost:9292"
132
+ end
133
+
129
134
  opts.on("-h", "--help", "Show this help") do
130
135
  puts opts
131
136
  exit 0
@@ -151,20 +156,22 @@ module RSpec
151
156
  color: options[:color],
152
157
  json_path: options[:json_path],
153
158
  html_path: options[:html_path],
154
- ui_mode: options[:ui_mode]
159
+ ui_mode: options[:ui_mode],
160
+ upload_url: options[:upload_url]
155
161
  )
156
162
  runner.run(options[:paths])
157
163
  end
158
164
 
159
165
  def parse_parallel_options(args)
160
166
  options = {
161
- workers: 4,
162
- fail_fast: false,
163
- paths: [],
164
- color: nil,
165
- json_path: nil,
166
- html_path: nil,
167
- ui_mode: nil
167
+ workers: 4,
168
+ fail_fast: false,
169
+ paths: [],
170
+ color: nil,
171
+ json_path: nil,
172
+ html_path: nil,
173
+ ui_mode: nil,
174
+ upload_url: nil
168
175
  }
169
176
 
170
177
  parser = OptionParser.new do |opts|
@@ -199,6 +206,10 @@ module RSpec
199
206
  options[:html_path] = path
200
207
  end
201
208
 
209
+ opts.on("--upload [URL]", "Upload run data to agents-studio (default: http://localhost:9292)") do |url|
210
+ options[:upload_url] = url || "http://localhost:9292"
211
+ end
212
+
202
213
  opts.on("-h", "--help", "Show this help") do
203
214
  puts opts
204
215
  exit 0
@@ -9,22 +9,15 @@ module RSpec
9
9
  @context = context
10
10
  end
11
11
 
12
- # Build a turn executor for step-by-step test conversations
12
+ # Get the shared turn executor from the test context
13
13
  # @return [TurnExecutor]
14
- def build_turn_executor
15
- TurnExecutor.new(
16
- agent: @context.build_agent,
17
- conversation: @context.conversation,
18
- graph: @context.topic_graph,
19
- judge: @context.build_judge(@context.build_llm),
20
- event_bus: @context.event_bus
21
- )
14
+ def turn_executor
15
+ @context.turn_executor
22
16
  end
23
17
 
24
18
  # Build an agent proxy for assertions
25
- # @param turn_executor [TurnExecutor]
26
19
  # @return [AgentProxy]
27
- def build_agent_proxy(turn_executor)
20
+ def build_agent_proxy
28
21
  AgentProxy.new(
29
22
  turn_executor: turn_executor,
30
23
  judge: @context.build_judge(@context.build_llm)
@@ -37,13 +30,12 @@ module RSpec
37
30
  def build_user_simulator(simulator_config)
38
31
  llm = @context.build_llm
39
32
  Runners::UserSimulator.new(
40
- agent: @context.build_agent,
33
+ turn_executor: turn_executor,
41
34
  llm: llm,
42
35
  judge: @context.build_judge(llm),
43
36
  graph: @context.topic_graph,
44
37
  simulator_config: simulator_config,
45
- event_bus: @context.event_bus,
46
- conversation: @context.conversation
38
+ event_bus: @context.event_bus
47
39
  )
48
40
  end
49
41
  end
@@ -55,6 +55,10 @@ module RSpec
55
55
  # end
56
56
  # end
57
57
  def scenario_set(name, from: nil, scenarios: nil, &block)
58
+ # Capture the caller location so examples get the correct source location
59
+ # instead of pointing to this DSL file
60
+ scenario_set_caller = caller
61
+
58
62
  # Validate arguments - must provide either from: or scenarios:, but not both
59
63
  if from.nil? && scenarios.nil?
60
64
  raise RSpec::Core::ExampleGroup::WrongScopeError,
@@ -92,6 +96,10 @@ module RSpec
92
96
  # Add scenario to metadata
93
97
  metadata[:rspec_agents_scenario] = captured_scenario
94
98
 
99
+ # Pass the caller from scenario_set so RSpec captures the correct
100
+ # source location (the spec file) instead of this DSL file
101
+ metadata[:caller] = scenario_set_caller
102
+
95
103
  # Create a wrapped test block that sets the instance variable
96
104
  wrapped_test_block = proc do
97
105
  @rspec_agents_current_scenario = captured_scenario
@@ -82,9 +82,7 @@ module RSpec
82
82
  @criteria = collect_inherited(:criteria)
83
83
  @simulator_config = build_simulator_config
84
84
  @topic_graph = nil
85
- # Use thread-local event bus if available (set by HeadlessRunner in parallel mode)
86
- # Otherwise use the singleton EventBus
87
- @event_bus = Thread.current[:rspec_agents_event_bus] || EventBus.instance
85
+ @event_bus = EventBus.current? ? EventBus.current : EventBus.new
88
86
  @conversation = Conversation.new(event_bus: @event_bus)
89
87
  end
90
88
 
@@ -110,6 +108,18 @@ module RSpec
110
108
  Judge.new(llm: llm, criteria: @criteria)
111
109
  end
112
110
 
111
+ # Shared turn executor for this test context.
112
+ # @return [TurnExecutor]
113
+ def turn_executor
114
+ @turn_executor ||= TurnExecutor.new(
115
+ agent: build_agent,
116
+ conversation: @conversation,
117
+ graph: @topic_graph,
118
+ judge: build_judge(build_llm),
119
+ event_bus: @event_bus
120
+ )
121
+ end
122
+
113
123
  # Merge additional simulator config (from test-level block)
114
124
  # @param override [SimulatorConfig]
115
125
  # @return [SimulatorConfig]
@@ -232,14 +232,15 @@ module RSpec
232
232
  def ensure_turn_executor_initialized
233
233
  return if @turn_executor
234
234
 
235
- factory = RunnerFactory.new(rspec_agents_test_context)
236
- @turn_executor = factory.build_turn_executor
235
+ context = rspec_agents_test_context
236
+ factory = RunnerFactory.new(context)
237
+ @turn_executor = context.turn_executor
237
238
  @user_proxy = UserProxy.new(
238
- context: rspec_agents_test_context,
239
+ context: context,
239
240
  turn_executor: @turn_executor,
240
241
  runner_factory: factory
241
242
  )
242
- @agent_proxy = factory.build_agent_proxy(@turn_executor)
243
+ @agent_proxy = factory.build_agent_proxy
243
244
  end
244
245
  end
245
246
 
@@ -1,11 +1,44 @@
1
- require "singleton"
1
+ # frozen_string_literal: true
2
2
 
3
3
  module RSpec
4
4
  module Agents
5
- # Central publish/subscribe hub for events
6
- # Thread-safe with error isolation (observer errors never fail tests)
5
+ # Central publish/subscribe hub for events.
6
+ # Thread-safe with error isolation (observer errors never fail tests).
7
+ #
8
+ # Access the current thread's event bus via EventBus.current.
9
+ # Each thread (or worker process) gets its own independent instance.
10
+ #
11
+ # @example Setup in a runner/executor
12
+ # EventBus.current = EventBus.new
13
+ # # ... run specs ...
14
+ # EventBus.current = nil # cleanup
15
+ #
16
+ # @example Consuming in observers
17
+ # EventBus.current.add_observer(self)
18
+ #
7
19
  class EventBus
8
- include Singleton
20
+ # Get the event bus for the current thread.
21
+ # Raises if no bus has been set up.
22
+ #
23
+ # @return [EventBus]
24
+ def self.current
25
+ Thread.current[:rspec_agents_event_bus] or
26
+ raise "No EventBus set for current thread. Call EventBus.current = EventBus.new first."
27
+ end
28
+
29
+ # Set the event bus for the current thread.
30
+ #
31
+ # @param bus [EventBus, nil]
32
+ def self.current=(bus)
33
+ Thread.current[:rspec_agents_event_bus] = bus
34
+ end
35
+
36
+ # Check whether an event bus has been set for the current thread.
37
+ #
38
+ # @return [Boolean]
39
+ def self.current?
40
+ !Thread.current[:rspec_agents_event_bus].nil?
41
+ end
9
42
 
10
43
  def initialize
11
44
  @subscribers = Hash.new { |h, k| h[k] = [] }
@@ -21,7 +21,7 @@ module RSpec
21
21
  # end
22
22
  #
23
23
  class Base
24
- def initialize(event_bus: EventBus.instance)
24
+ def initialize(event_bus:)
25
25
  event_bus.add_observer(self)
26
26
  end
27
27
 
@@ -29,7 +29,7 @@ module RSpec
29
29
  # @param output [IO] Output stream (default: $stdout)
30
30
  # @param color [Boolean, nil] Force color on/off (default: auto-detect)
31
31
  # @param event_bus [EventBus] Event bus to subscribe to
32
- def initialize(output: $stdout, color: nil, event_bus: EventBus.instance)
32
+ def initialize(output: $stdout, color: nil, event_bus:)
33
33
  @output = output
34
34
  @color = color.nil? ? output.respond_to?(:tty?) && output.tty? : color
35
35
  @failures = []
@@ -160,6 +160,11 @@ module RSpec
160
160
  append_to_buffer(worker, " #{colorize(line.chomp, :red)}")
161
161
  end
162
162
  end
163
+ if event.backtrace&.any?
164
+ event.backtrace.first(3).each do |line|
165
+ append_to_buffer(worker, " #{colorize(line, :dim)}")
166
+ end
167
+ end
163
168
 
164
169
  when Events::ExamplePending
165
170
  @worker_status[worker] = :pending
@@ -69,6 +69,11 @@ module RSpec
69
69
  @output.puts "#{prefix} #{colorize(line.chomp, :red)}"
70
70
  end
71
71
  end
72
+ if event.backtrace&.any?
73
+ event.backtrace.first(3).each do |line|
74
+ @output.puts "#{prefix} #{colorize(line, :dim)}"
75
+ end
76
+ end
72
77
 
73
78
  when Events::ExamplePending
74
79
  message = event.message ? " (#{event.message})" : ""
@@ -20,6 +20,7 @@ module RSpec
20
20
  # EventBus → RpcNotifyObserver → JSON over RPC socket
21
21
  #
22
22
  class HeadlessRunner
23
+ include BacktraceHelper
23
24
  # RSpec notifications we subscribe to
24
25
  NOTIFICATIONS = [
25
26
  :start,
@@ -48,8 +49,8 @@ module RSpec
48
49
  @example_count = 0
49
50
  @failure_count = 0
50
51
 
51
- # Create isolated EventBus for this worker (NOT the singleton)
52
- @event_bus = IsolatedEventBus.new
52
+ # Create EventBus for this worker
53
+ @event_bus = EventBus.new
53
54
 
54
55
  # Set up RPC forwarding - all events go through this observer
55
56
  Observers::RpcNotifyObserver.new(
@@ -84,15 +85,15 @@ module RSpec
84
85
  # Register ourselves as a listener for RSpec lifecycle events
85
86
  RSpec.configuration.reporter.register_listener(self, *NOTIFICATIONS)
86
87
 
87
- # Inject event bus into test context for Conversation to find
88
- Thread.current[:rspec_agents_event_bus] = @event_bus
88
+ # Set event bus for the current thread so Conversation finds it
89
+ EventBus.current = @event_bus
89
90
 
90
91
  # Run specs
91
92
  runner = RSpec::Core::Runner.new(options)
92
93
  exit_code = runner.run($stderr, null_output)
93
94
 
94
95
  # Clean up thread-locals
95
- Thread.current[:rspec_agents_event_bus] = nil
96
+ EventBus.current = nil
96
97
  Thread.current[:rspec_agents_example_id] = nil
97
98
 
98
99
  {
@@ -241,31 +242,6 @@ module RSpec
241
242
  nil
242
243
  end
243
244
 
244
- def extract_failure_message(notification)
245
- if notification.respond_to?(:exception) && notification.exception
246
- notification.exception.message
247
- elsif notification.example.execution_result.exception
248
- notification.example.execution_result.exception.message
249
- end
250
- end
251
-
252
- def extract_backtrace(notification)
253
- backtrace = if notification.respond_to?(:formatted_backtrace)
254
- notification.formatted_backtrace
255
- elsif notification.example.execution_result.exception
256
- notification.example.execution_result.exception.backtrace
257
- end
258
- filter_backtrace(backtrace)
259
- end
260
-
261
- def filter_backtrace(backtrace)
262
- return nil unless backtrace
263
-
264
- # Include only lines from the current working directory (application code)
265
- # This matches RSpec's backtrace_inclusion_patterns approach
266
- app_path = Dir.getwd
267
- backtrace.select { |line| line.start_with?(app_path) }.first(10)
268
- end
269
245
  end
270
246
  end
271
247
  end
@@ -3,6 +3,7 @@
3
3
  require "async"
4
4
  require "fileutils"
5
5
  require_relative "../parallel/ui/ui_factory"
6
+ require_relative "run_data_uploader"
6
7
 
7
8
  module RSpec
8
9
  module Agents
@@ -43,7 +44,7 @@ module RSpec
43
44
  # @param html_path [String, nil] Path to save HTML report
44
45
  # @param ui_mode [Symbol, nil] Output mode (:interactive, :interleaved, :quiet)
45
46
  def initialize(worker_count:, fail_fast: false, output: $stdout, color: nil,
46
- json_path: nil, html_path: nil, ui_mode: nil)
47
+ json_path: nil, html_path: nil, ui_mode: nil, upload_url: nil)
47
48
  @worker_count = worker_count
48
49
  @fail_fast = fail_fast
49
50
  @output = output
@@ -51,6 +52,7 @@ module RSpec
51
52
  @mutex = Mutex.new
52
53
  @json_path = json_path
53
54
  @html_path = html_path
55
+ @upload_url = upload_url
54
56
 
55
57
  @ui = Parallel::UI::UIFactory.create(
56
58
  mode: ui_mode,
@@ -84,7 +86,7 @@ module RSpec
84
86
  end
85
87
  end
86
88
  rescue Parallel::ExampleDiscovery::DiscoveryError => e
87
- @output.puts colorize("Error: #{e.message}", :red)
89
+ @output.puts colorize("Example discovery failed: #{e.message}", :red)
88
90
  return 1
89
91
  end
90
92
 
@@ -113,7 +115,7 @@ module RSpec
113
115
  end
114
116
 
115
117
  # Save outputs
116
- save_outputs(executor.run_data) if @json_path || @html_path
118
+ save_outputs(executor.run_data) if @json_path || @html_path || @upload_url
117
119
 
118
120
  result&.success? ? 0 : 1
119
121
  ensure
@@ -192,7 +194,7 @@ module RSpec
192
194
  failures.each do |event|
193
195
  location = event.location
194
196
  description = event.full_description || event.description
195
- @output.puts colorize("bin/rspec-agents #{location}", :red) + " " + colorize("# #{description}", :dim)
197
+ @output.puts colorize("rspec-agents #{location}", :red) + " " + colorize("# #{description}", :dim)
196
198
  end
197
199
  @output.puts
198
200
  end
@@ -206,6 +208,7 @@ module RSpec
206
208
  return unless run_data
207
209
 
208
210
  if @json_path
211
+ populate_rendered_extensions(run_data)
209
212
  FileUtils.mkdir_p(File.dirname(@json_path))
210
213
  Serialization::JsonFile.write(@json_path, run_data)
211
214
  end
@@ -213,6 +216,27 @@ module RSpec
213
216
  if @html_path
214
217
  Serialization::TestSuiteRenderer.render(run_data, output_path: @html_path)
215
218
  end
219
+
220
+ if @upload_url
221
+ RunDataUploader.new(url: @upload_url, output: @output).upload(run_data)
222
+ end
223
+ end
224
+
225
+ def populate_rendered_extensions(run_data)
226
+ extensions = [Serialization::Extensions::CoreExtension] +
227
+ RSpec::Agents.configuration.html_extensions
228
+ run_data.examples.each_value do |example|
229
+ next unless example.conversation
230
+
231
+ renderer = Serialization::ConversationRenderer.new(
232
+ example.conversation,
233
+ extensions: extensions,
234
+ example_id: example.stable_id || example.id
235
+ )
236
+ example.rendered_extensions = renderer.build_rendered_extensions
237
+ end
238
+ rescue StandardError => e
239
+ warn "Warning: Failed to generate rendered_extensions: #{e.message}"
216
240
  end
217
241
  end
218
242
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module RSpec
8
+ module Agents
9
+ module Runners
10
+ # Uploads run data to an agents-studio webapp via HTTP POST.
11
+ # Used by TerminalRunner and ParallelTerminalRunner when --upload is specified.
12
+ #
13
+ # @example
14
+ # uploader = RunDataUploader.new(url: "http://localhost:9292")
15
+ # uploader.upload(run_data) # => true/false
16
+ #
17
+ class RunDataUploader
18
+ TIMEOUT = 30 # seconds
19
+
20
+ # @param url [String] Base URL of the agents-studio webapp
21
+ # @param output [IO] Output stream for status messages
22
+ def initialize(url:, output: $stdout)
23
+ @url = url.chomp("/")
24
+ @output = output
25
+ end
26
+
27
+ # Upload run data to the webapp.
28
+ # @param run_data [Serialization::RunData] The run data to upload
29
+ # @return [Boolean] true if upload succeeded
30
+ def upload(run_data)
31
+ return false unless run_data
32
+
33
+ uri = URI.parse("#{@url}/api/import")
34
+ json_body = JSON.generate(run_data.to_h)
35
+
36
+ @output.puts "Uploading run data to #{@url}..."
37
+
38
+ http = Net::HTTP.new(uri.host, uri.port)
39
+ http.use_ssl = (uri.scheme == "https")
40
+ http.open_timeout = TIMEOUT
41
+ http.read_timeout = TIMEOUT
42
+
43
+ request = Net::HTTP::Post.new(uri.path)
44
+ request["Content-Type"] = "application/json"
45
+ request.body = json_body
46
+
47
+ response = http.request(request)
48
+
49
+ if response.is_a?(Net::HTTPSuccess)
50
+ result = JSON.parse(response.body)
51
+ @output.puts "Upload complete: #{result["example_count"]} examples " \
52
+ "(#{result["passed_count"]} passed, #{result["failed_count"]} failed)"
53
+ true
54
+ else
55
+ @output.puts "Upload failed (HTTP #{response.code}): #{response.body}"
56
+ false
57
+ end
58
+ rescue Errno::ECONNREFUSED
59
+ @output.puts "Upload failed: could not connect to #{@url} (is agents-studio running?)"
60
+ false
61
+ rescue Errno::ETIMEDOUT, Net::OpenTimeout, Net::ReadTimeout
62
+ @output.puts "Upload failed: connection to #{@url} timed out"
63
+ false
64
+ rescue StandardError => e
65
+ @output.puts "Upload failed: #{e.message}"
66
+ false
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end