@specsage/cli 0.1.8 → 0.1.11

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.
package/lib/cli.rb CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  require 'optparse'
8
8
  require 'fileutils'
9
+ require 'json'
9
10
 
10
11
  # Set the package root directory
11
12
  SPECSAGE_HOME = File.expand_path('..', __dir__)
@@ -17,7 +18,7 @@ require 'runner'
17
18
  require 'results_uploader'
18
19
 
19
20
  class SpecSageCLI
20
- VERSION = '0.1.6'
21
+ VERSION = JSON.parse(File.read(File.join(SPECSAGE_HOME, 'package.json')))['version']
21
22
 
22
23
  def initialize(args)
23
24
  @args = args
@@ -80,10 +81,6 @@ class SpecSageCLI
80
81
  metadata
81
82
  end
82
83
 
83
- def self_test_mode?
84
- ENV['SPECSAGE_APP_TEST'] == 'true'
85
- end
86
-
87
84
  def run_ci_mode
88
85
  website = ENV['TARGET_WEBSITE_SLUG']
89
86
  api_key = ENV['SPEC_SAGE_API_KEY']
@@ -104,9 +101,6 @@ class SpecSageCLI
104
101
  puts "Version: #{VERSION}"
105
102
  puts "Website: #{website}"
106
103
  puts "Base URL: #{base_url}"
107
- if self_test_mode?
108
- puts "Self-test mode: orchestration via #{base_url}"
109
- end
110
104
  puts ""
111
105
 
112
106
  publisher = ResultsUploader.new(api_key: api_key)
@@ -123,6 +117,9 @@ class SpecSageCLI
123
117
  scenarios.each { |s| puts " - #{s['name']}" }
124
118
  puts ""
125
119
 
120
+ # Build all_scenarios map for pre-scenario lookup
121
+ all_scenarios = scenarios.each_with_object({}) { |s, map| map[s['id']] = s }
122
+
126
123
  begin
127
124
  run_response = publisher.create_ci_run(website, ci_metadata: build_ci_metadata)
128
125
  server_run_id = run_response['server_run_id']
@@ -146,7 +143,7 @@ class SpecSageCLI
146
143
  record: @options[:record],
147
144
  publisher: publisher,
148
145
  server_run_id: server_run_id,
149
- orchestration_base_url: self_test_mode? ? base_url : nil
146
+ all_scenarios: all_scenarios
150
147
  )
151
148
 
152
149
  verdict = runner.run
@@ -6,7 +6,6 @@ require 'json'
6
6
 
7
7
  class ResultsUploader
8
8
  DEFAULT_BASE_URL = 'https://api.specsage.com'
9
- VERSION = '0.1.0'
10
9
 
11
10
  class UploadError < StandardError; end
12
11
 
package/lib/runner.rb CHANGED
@@ -21,9 +21,8 @@ class Runner
21
21
  SAFE_PATH_SEGMENT = /\A[a-zA-Z0-9_-]+\z/
22
22
 
23
23
  # Initialize runner with scenario data from server
24
- # orchestration_base_url: URL for step submissions (LLM orchestration). Defaults to publisher.base_url.
25
- # Set to CI_APP_URL when SPECSAGE_APP_TEST=true to test server code locally.
26
- def initialize(scenario_data, visible: false, record: false, publisher: nil, server_run_id: nil, orchestration_base_url: nil)
24
+ # @param all_scenarios [Hash] optional map of scenario_id => scenario_data for pre-scenario lookup
25
+ def initialize(scenario_data, visible: false, record: false, publisher: nil, server_run_id: nil, all_scenarios: nil)
27
26
  @scenario = normalize_scenario_data(scenario_data)
28
27
  @scenario_id = @scenario['id']
29
28
  @scenario_name = @scenario['name'] || @scenario['id'] || 'unnamed'
@@ -36,12 +35,12 @@ class Runner
36
35
  @next_request_id = 1
37
36
  @node_channel_poisoned = false
38
37
  @publisher = publisher
39
- @orchestration_base_url = orchestration_base_url || publisher&.base_url
40
38
  @step_client = nil
41
39
  @server_run_id = server_run_id
42
40
  @credentials = {} # Credentials received from server { "NAME" => "value" }
43
41
  @max_steps = nil # Max browser actions allowed, received from server on first step
44
42
  @temp_dir = nil # Unique temp directory for this runner's video recording
43
+ @all_scenarios = all_scenarios || {} # Map of scenario_id => scenario_data for pre-scenario lookup
45
44
  end
46
45
 
47
46
  def run
@@ -50,37 +49,124 @@ class Runner
50
49
  raise ArgumentError, 'server_run_id is required' unless @server_run_id
51
50
 
52
51
  @step_client = StepClient.new(
53
- base_url: @orchestration_base_url,
52
+ base_url: @publisher.base_url,
54
53
  server_run_id: @server_run_id,
55
54
  api_key: @publisher.api_key
56
55
  )
57
56
 
58
57
  start_node_process
59
- initial_state = navigate_to_base_url
60
58
 
61
- current_screenshot_base64 = initial_state[:screenshot_base64]
62
- interactive_elements = initial_state[:elements]
59
+ # Run pre-scenarios first (if any)
60
+ pre_scenario_ids = @scenario['pre_scenario_ids'] || []
61
+ if pre_scenario_ids.any?
62
+ log "Running #{pre_scenario_ids.length} pre-scenario(s) before main scenario"
63
+
64
+ pre_scenario_ids.each_with_index do |pre_scenario_id, index|
65
+ pre_scenario_data = @all_scenarios[pre_scenario_id]
66
+ unless pre_scenario_data
67
+ log "ERROR: Pre-scenario #{pre_scenario_id} not found in scenarios list"
68
+ send_main_scenario_verdict('PRE_SCENARIO_ERROR', "Pre-scenario not found: #{pre_scenario_id}")
69
+ stop_node_process
70
+ upload_video
71
+ cleanup_temp_dir
72
+ return
73
+ end
74
+
75
+ pre_scenario_name = pre_scenario_data['name'] || pre_scenario_id
76
+ log "Running pre-scenario #{index + 1}/#{pre_scenario_ids.length}: #{pre_scenario_name}"
77
+
78
+ # Use a composite scenario_id for pre-scenarios to avoid collision with standalone runs
79
+ # Format: "pre_{main_scenario_id}_{pre_scenario_id}" ensures uniqueness per run
80
+ composite_scenario_id = "pre_#{@scenario_id}_#{pre_scenario_id}"
81
+
82
+ result = run_single_scenario(
83
+ scenario_id: composite_scenario_id,
84
+ scenario_data: pre_scenario_data,
85
+ pre_scenario_for_id: @scenario_id,
86
+ execution_order: index + 1
87
+ )
88
+
89
+ if result[:verdict] == 'FAIL'
90
+ log "Pre-scenario '#{pre_scenario_name}' FAILED - skipping main scenario"
91
+ send_main_scenario_verdict('PRE_SCENARIO_FAIL', "Pre-scenario '#{pre_scenario_name}' failed")
92
+ stop_node_process
93
+ upload_video
94
+ cleanup_temp_dir
95
+ return
96
+ elsif result[:verdict] == 'ERROR'
97
+ log "Pre-scenario '#{pre_scenario_name}' ERROR - skipping main scenario"
98
+ send_main_scenario_verdict('PRE_SCENARIO_ERROR', "Pre-scenario '#{pre_scenario_name}' errored: #{result[:reason]}")
99
+ stop_node_process
100
+ upload_video
101
+ cleanup_temp_dir
102
+ return
103
+ end
104
+
105
+ log "Pre-scenario '#{pre_scenario_name}' PASSED"
106
+ end
107
+
108
+ log "All pre-scenarios passed, running main scenario"
109
+ end
110
+
111
+ # Run the main scenario
112
+ run_single_scenario(
113
+ scenario_id: @scenario_id,
114
+ scenario_data: @scenario,
115
+ pre_scenario_for_id: nil,
116
+ execution_order: 0
117
+ )
118
+
119
+ stop_node_process
120
+ upload_video
121
+ cleanup_temp_dir
122
+ rescue StepClient::StepError => e
123
+ send_client_verdict_if_needed('ERROR', "Server error: #{e.message}")
124
+ stop_node_process
125
+ upload_video
126
+ cleanup_temp_dir
127
+ rescue StandardError => e
128
+ send_client_verdict_if_needed('ERROR', e.message)
129
+ stop_node_process
130
+ upload_video
131
+ cleanup_temp_dir
132
+ end
133
+
134
+ # Run a single scenario (either pre-scenario or main scenario)
135
+ # Returns { verdict: 'PASS'|'FAIL'|'ERROR', reason: String }
136
+ def run_single_scenario(scenario_id:, scenario_data:, pre_scenario_for_id:, execution_order:)
137
+ scenario_name = scenario_data['name'] || scenario_id
138
+ base_url = scenario_data['base_url']
139
+
140
+ # Navigate to scenario's base URL
141
+ initial_state = send_to_node('navigate', { url: base_url })
142
+ current_screenshot_base64 = initial_state.dig('result', 'screenshot_base64')
143
+ interactive_elements = initial_state.dig('result', 'elements') || []
63
144
  interactive_elements_by_id = build_elements_by_id(interactive_elements)
64
145
  previous_action = nil
65
146
  action_result = nil
66
- final_verdict = 'ERROR' # Default to ERROR if no verdict received
147
+
148
+ # Reset credentials and max_steps for each scenario (each has its own)
149
+ scenario_credentials = {}
150
+ scenario_max_steps = nil
67
151
 
68
152
  loop do
69
153
  # Get next action from server
70
154
  step_result = @step_client.submit_step(
71
- scenario_id: @scenario_id,
155
+ scenario_id: scenario_id,
72
156
  screenshot_base64: current_screenshot_base64,
73
157
  elements: interactive_elements,
74
158
  previous_action: previous_action,
75
- action_result: action_result
159
+ action_result: action_result,
160
+ pre_scenario_for_id: pre_scenario_for_id,
161
+ execution_order: execution_order
76
162
  )
77
163
 
78
164
  # Store credentials and max_steps from server on first step only
79
- # Server sends these once at scenario start, runner caches them for the duration
80
165
  if step_result[:credentials] && !step_result[:credentials].empty?
81
- @credentials = step_result[:credentials]
166
+ scenario_credentials = step_result[:credentials]
167
+ @credentials = scenario_credentials # Set for credential substitution
82
168
  end
83
- @max_steps ||= step_result[:max_steps]
169
+ scenario_max_steps ||= step_result[:max_steps]
84
170
 
85
171
  action = step_result[:action]
86
172
 
@@ -95,12 +181,11 @@ class Runner
95
181
  end
96
182
  end
97
183
 
98
- # Log action with element details
99
- safe_puts " [Step #{step_result[:step_number]}] #{action['action']}: #{action.reject { |k, _| k == 'action' }.to_json}"
184
+ # Log action with element details (using scenario_name for context)
185
+ puts "[#{scenario_name}] [Step #{step_result[:step_number]}] #{action['action']}: #{action.reject { |k, _| k == 'action' }.to_json}"
100
186
 
101
187
  if action['action'] == 'verdict'
102
- final_verdict = action['status'] || 'ERROR'
103
- break
188
+ return { verdict: action['status'], reason: action['reason'] }
104
189
  end
105
190
 
106
191
  # Skip browser execution for meta actions
@@ -113,8 +198,6 @@ class Runner
113
198
  result = execute_action(action)
114
199
 
115
200
  # Update state from action result
116
- # Note: screenshot may be nil if a dialog is blocking (can't screenshot while dialog is up)
117
- # but we still need to update elements (which will contain the DIALOG pseudo-element)
118
201
  if result[:screenshot_base64]
119
202
  current_screenshot_base64 = result[:screenshot_base64]
120
203
  end
@@ -127,35 +210,36 @@ class Runner
127
210
  action_result = result[:result]
128
211
 
129
212
  # Check if step count has reached max_steps limit
130
- # This check happens AFTER executing the action, so max_steps=4 means 4 actions execute
131
213
  step_number = step_result[:step_number] || 0
132
- if @max_steps && step_number >= @max_steps
133
- log "Step limit exceeded (#{@max_steps})."
134
- send_client_verdict_if_needed('ERROR', "Step limit exceeded (#{@max_steps}).")
135
- final_verdict = 'ERROR'
136
- break
214
+ if scenario_max_steps && step_number >= scenario_max_steps
215
+ puts "[#{scenario_name}] Step limit exceeded (#{scenario_max_steps})."
216
+ @step_client.set_verdict(
217
+ scenario_id: scenario_id,
218
+ status: 'ERROR',
219
+ reason: "Step limit exceeded (#{scenario_max_steps})."
220
+ )
221
+ return { verdict: 'ERROR', reason: "Step limit exceeded (#{scenario_max_steps})" }
137
222
  end
138
223
 
139
224
  # Check if server says we should stop
140
- break unless step_result[:continue]
225
+ unless step_result[:continue]
226
+ return { verdict: 'ERROR', reason: 'Server stopped scenario unexpectedly' }
227
+ end
141
228
  end
229
+ end
142
230
 
143
- stop_node_process
144
- upload_video
145
- cleanup_temp_dir
146
- final_verdict
231
+ # Send a verdict for the main scenario when pre-scenarios fail
232
+ def send_main_scenario_verdict(status, reason)
233
+ return unless @step_client
234
+
235
+ log "Setting main scenario verdict to #{status}: #{reason}"
236
+ @step_client.set_verdict(
237
+ scenario_id: @scenario_id,
238
+ status: status,
239
+ reason: reason
240
+ )
147
241
  rescue StepClient::StepError => e
148
- send_client_verdict_if_needed('ERROR', "Server error: #{e.message}")
149
- stop_node_process
150
- upload_video
151
- cleanup_temp_dir
152
- 'ERROR'
153
- rescue StandardError => e
154
- send_client_verdict_if_needed('ERROR', e.message)
155
- stop_node_process
156
- upload_video
157
- cleanup_temp_dir
158
- 'ERROR'
242
+ log "Warning: Failed to send main scenario verdict: #{e.message}"
159
243
  end
160
244
 
161
245
  private
@@ -166,12 +250,13 @@ class Runner
166
250
  {
167
251
  'id' => data['id'] || data[:id],
168
252
  'name' => data['name'] || data[:name],
169
- 'base_url' => data['base_url'] || data[:base_url]
253
+ 'base_url' => data['base_url'] || data[:base_url],
254
+ 'pre_scenario_ids' => data['pre_scenario_ids'] || data[:pre_scenario_ids] || []
170
255
  }
171
256
  end
172
257
 
173
258
  def start_node_process
174
- node_script = File.join(SPECSAGE_HOME, 'lib', 'browser.js')
259
+ node_script = File.join(__dir__, 'browser.js')
175
260
  raise "browser.js not found at #{node_script}" unless File.exist?(node_script)
176
261
 
177
262
  # Create unique temp directory for this runner's video recording
@@ -242,6 +327,17 @@ class Runner
242
327
  video_path = response&.dig('result', 'video_path')
243
328
  if video_path
244
329
  if File.exist?(video_path)
330
+ # Playwright WebM files lack seek metadata (Cues), so Chrome can't scrub them.
331
+ # Remux with ffmpeg to add the seek index without re-encoding.
332
+ remuxed_path = video_path.sub(/\.webm$/, '_remuxed.webm')
333
+ if system('ffmpeg', '-i', video_path, '-c', 'copy', remuxed_path, '-y', '-loglevel', 'error')
334
+ log "Video remuxed for seekability: #{remuxed_path}"
335
+ File.delete(video_path) rescue nil
336
+ video_path = remuxed_path
337
+ else
338
+ log "Warning: ffmpeg remux failed, using original video (scrubbing may not work)"
339
+ end
340
+
245
341
  @video_data = File.binread(video_path)
246
342
  log "Video captured: #{video_path} (#{@video_data&.bytesize} bytes)"
247
343
  File.delete(video_path) rescue nil
@@ -364,16 +460,6 @@ class Runner
364
460
  end
365
461
  end
366
462
 
367
- def navigate_to_base_url
368
- base_url = @scenario['base_url']
369
- raise 'base_url not specified in scenario file' unless base_url
370
-
371
- response = send_to_node('navigate', { url: base_url })
372
- screenshot_base64 = response.dig('result', 'screenshot_base64')
373
- elements = response.dig('result', 'elements') || []
374
- { screenshot_base64: screenshot_base64, elements: elements }
375
- end
376
-
377
463
  def execute_action(action)
378
464
  case action['action']
379
465
  when 'navigate'
@@ -41,15 +41,18 @@ class StepClient
41
41
 
42
42
  # Submit a step to the server
43
43
  # Returns: { action: Hash, step_number: Integer, continue: Boolean }
44
- def submit_step(scenario_id:, screenshot_base64:, elements:, previous_action: nil, action_result: nil)
44
+ def submit_step(scenario_id:, screenshot_base64:, elements:, previous_action: nil, action_result: nil,
45
+ pre_scenario_for_id: nil, execution_order: 0)
45
46
  body = {
46
47
  scenario_id: scenario_id,
47
48
  screenshot_base64: screenshot_base64,
48
- elements: elements
49
+ elements: elements,
50
+ execution_order: execution_order
49
51
  }
50
52
 
51
53
  body[:previous_action] = previous_action if previous_action
52
54
  body[:action_result] = action_result if action_result
55
+ body[:pre_scenario_for_id] = pre_scenario_for_id if pre_scenario_for_id
53
56
 
54
57
  response = post("/api/runs/#{@server_run_id}/step", body)
55
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specsage/cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.11",
4
4
  "description": "SpecSage CLI - AI-powered end-to-end testing automation (Node wrapper for Ruby CLI)",
5
5
  "type": "module",
6
6
  "bin": {