@specsage/cli 0.1.9 → 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
@@ -116,6 +117,9 @@ class SpecSageCLI
116
117
  scenarios.each { |s| puts " - #{s['name']}" }
117
118
  puts ""
118
119
 
120
+ # Build all_scenarios map for pre-scenario lookup
121
+ all_scenarios = scenarios.each_with_object({}) { |s, map| map[s['id']] = s }
122
+
119
123
  begin
120
124
  run_response = publisher.create_ci_run(website, ci_metadata: build_ci_metadata)
121
125
  server_run_id = run_response['server_run_id']
@@ -138,7 +142,8 @@ class SpecSageCLI
138
142
  visible: @options[:visible],
139
143
  record: @options[:record],
140
144
  publisher: publisher,
141
- server_run_id: server_run_id
145
+ server_run_id: server_run_id,
146
+ all_scenarios: all_scenarios
142
147
  )
143
148
 
144
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,7 +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
- def initialize(scenario_data, visible: false, record: false, publisher: nil, server_run_id: 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)
25
26
  @scenario = normalize_scenario_data(scenario_data)
26
27
  @scenario_id = @scenario['id']
27
28
  @scenario_name = @scenario['name'] || @scenario['id'] || 'unnamed'
@@ -39,6 +40,7 @@ class Runner
39
40
  @credentials = {} # Credentials received from server { "NAME" => "value" }
40
41
  @max_steps = nil # Max browser actions allowed, received from server on first step
41
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
42
44
  end
43
45
 
44
46
  def run
@@ -53,31 +55,118 @@ class Runner
53
55
  )
54
56
 
55
57
  start_node_process
56
- initial_state = navigate_to_base_url
57
58
 
58
- current_screenshot_base64 = initial_state[:screenshot_base64]
59
- 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') || []
60
144
  interactive_elements_by_id = build_elements_by_id(interactive_elements)
61
145
  previous_action = nil
62
146
  action_result = nil
63
- 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
64
151
 
65
152
  loop do
66
153
  # Get next action from server
67
154
  step_result = @step_client.submit_step(
68
- scenario_id: @scenario_id,
155
+ scenario_id: scenario_id,
69
156
  screenshot_base64: current_screenshot_base64,
70
157
  elements: interactive_elements,
71
158
  previous_action: previous_action,
72
- action_result: action_result
159
+ action_result: action_result,
160
+ pre_scenario_for_id: pre_scenario_for_id,
161
+ execution_order: execution_order
73
162
  )
74
163
 
75
164
  # Store credentials and max_steps from server on first step only
76
- # Server sends these once at scenario start, runner caches them for the duration
77
165
  if step_result[:credentials] && !step_result[:credentials].empty?
78
- @credentials = step_result[:credentials]
166
+ scenario_credentials = step_result[:credentials]
167
+ @credentials = scenario_credentials # Set for credential substitution
79
168
  end
80
- @max_steps ||= step_result[:max_steps]
169
+ scenario_max_steps ||= step_result[:max_steps]
81
170
 
82
171
  action = step_result[:action]
83
172
 
@@ -92,12 +181,11 @@ class Runner
92
181
  end
93
182
  end
94
183
 
95
- # Log action with element details
96
- 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}"
97
186
 
98
187
  if action['action'] == 'verdict'
99
- final_verdict = action['status'] || 'ERROR'
100
- break
188
+ return { verdict: action['status'], reason: action['reason'] }
101
189
  end
102
190
 
103
191
  # Skip browser execution for meta actions
@@ -110,8 +198,6 @@ class Runner
110
198
  result = execute_action(action)
111
199
 
112
200
  # Update state from action result
113
- # Note: screenshot may be nil if a dialog is blocking (can't screenshot while dialog is up)
114
- # but we still need to update elements (which will contain the DIALOG pseudo-element)
115
201
  if result[:screenshot_base64]
116
202
  current_screenshot_base64 = result[:screenshot_base64]
117
203
  end
@@ -124,35 +210,36 @@ class Runner
124
210
  action_result = result[:result]
125
211
 
126
212
  # Check if step count has reached max_steps limit
127
- # This check happens AFTER executing the action, so max_steps=4 means 4 actions execute
128
213
  step_number = step_result[:step_number] || 0
129
- if @max_steps && step_number >= @max_steps
130
- log "Step limit exceeded (#{@max_steps})."
131
- send_client_verdict_if_needed('ERROR', "Step limit exceeded (#{@max_steps}).")
132
- final_verdict = 'ERROR'
133
- 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})" }
134
222
  end
135
223
 
136
224
  # Check if server says we should stop
137
- break unless step_result[:continue]
225
+ unless step_result[:continue]
226
+ return { verdict: 'ERROR', reason: 'Server stopped scenario unexpectedly' }
227
+ end
138
228
  end
229
+ end
139
230
 
140
- stop_node_process
141
- upload_video
142
- cleanup_temp_dir
143
- 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
+ )
144
241
  rescue StepClient::StepError => e
145
- send_client_verdict_if_needed('ERROR', "Server error: #{e.message}")
146
- stop_node_process
147
- upload_video
148
- cleanup_temp_dir
149
- 'ERROR'
150
- rescue StandardError => e
151
- send_client_verdict_if_needed('ERROR', e.message)
152
- stop_node_process
153
- upload_video
154
- cleanup_temp_dir
155
- 'ERROR'
242
+ log "Warning: Failed to send main scenario verdict: #{e.message}"
156
243
  end
157
244
 
158
245
  private
@@ -163,12 +250,13 @@ class Runner
163
250
  {
164
251
  'id' => data['id'] || data[:id],
165
252
  'name' => data['name'] || data[:name],
166
- '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] || []
167
255
  }
168
256
  end
169
257
 
170
258
  def start_node_process
171
- node_script = File.join(SPECSAGE_HOME, 'lib', 'browser.js')
259
+ node_script = File.join(__dir__, 'browser.js')
172
260
  raise "browser.js not found at #{node_script}" unless File.exist?(node_script)
173
261
 
174
262
  # Create unique temp directory for this runner's video recording
@@ -239,6 +327,17 @@ class Runner
239
327
  video_path = response&.dig('result', 'video_path')
240
328
  if video_path
241
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
+
242
341
  @video_data = File.binread(video_path)
243
342
  log "Video captured: #{video_path} (#{@video_data&.bytesize} bytes)"
244
343
  File.delete(video_path) rescue nil
@@ -361,16 +460,6 @@ class Runner
361
460
  end
362
461
  end
363
462
 
364
- def navigate_to_base_url
365
- base_url = @scenario['base_url']
366
- raise 'base_url not specified in scenario file' unless base_url
367
-
368
- response = send_to_node('navigate', { url: base_url })
369
- screenshot_base64 = response.dig('result', 'screenshot_base64')
370
- elements = response.dig('result', 'elements') || []
371
- { screenshot_base64: screenshot_base64, elements: elements }
372
- end
373
-
374
463
  def execute_action(action)
375
464
  case action['action']
376
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.9",
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": {