@specsage/cli 0.1.9 → 0.1.12

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/browser.js CHANGED
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // DO NOT EDIT packages/cli/lib/browser.js - it is copied from this file during npm publish.
4
+ // See bin/publish_npm_package for details.
5
+
3
6
  import { chromium } from "playwright";
4
7
  import fs from "fs";
5
8
  import path from "path";
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
package/lib/dialogs.js CHANGED
@@ -2,6 +2,9 @@
2
2
  * Dialog handling module for SpecSage browser automation.
3
3
  * Captures JavaScript dialogs (alert, confirm, prompt) and exposes them
4
4
  * to the AI agent via visual overlays and pseudo-elements.
5
+ *
6
+ * DO NOT EDIT packages/cli/lib/dialogs.js - it is copied from this file during npm publish.
7
+ * See bin/publish_npm_package for details.
5
8
  */
6
9
 
7
10
  // Pending dialog state
@@ -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
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ # DO NOT EDIT packages/cli/lib/runner.rb - it is copied from this file during npm publish.
5
+ # See bin/publish_npm_package for details.
6
+
4
7
  require 'json'
5
8
  require 'timeout'
6
9
  require 'fileutils'
@@ -21,7 +24,8 @@ class Runner
21
24
  SAFE_PATH_SEGMENT = /\A[a-zA-Z0-9_-]+\z/
22
25
 
23
26
  # Initialize runner with scenario data from server
24
- def initialize(scenario_data, visible: false, record: false, publisher: nil, server_run_id: nil)
27
+ # @param all_scenarios [Hash] optional map of scenario_id => scenario_data for pre-scenario lookup
28
+ def initialize(scenario_data, visible: false, record: false, publisher: nil, server_run_id: nil, all_scenarios: nil)
25
29
  @scenario = normalize_scenario_data(scenario_data)
26
30
  @scenario_id = @scenario['id']
27
31
  @scenario_name = @scenario['name'] || @scenario['id'] || 'unnamed'
@@ -39,6 +43,7 @@ class Runner
39
43
  @credentials = {} # Credentials received from server { "NAME" => "value" }
40
44
  @max_steps = nil # Max browser actions allowed, received from server on first step
41
45
  @temp_dir = nil # Unique temp directory for this runner's video recording
46
+ @all_scenarios = all_scenarios || {} # Map of scenario_id => scenario_data for pre-scenario lookup
42
47
  end
43
48
 
44
49
  def run
@@ -53,31 +58,122 @@ class Runner
53
58
  )
54
59
 
55
60
  start_node_process
56
- initial_state = navigate_to_base_url
57
61
 
58
- current_screenshot_base64 = initial_state[:screenshot_base64]
59
- interactive_elements = initial_state[:elements]
62
+ # Run pre-scenarios first (if any)
63
+ pre_scenario_ids = @scenario['pre_scenario_ids'] || []
64
+ if pre_scenario_ids.any?
65
+ log "Running #{pre_scenario_ids.length} pre-scenario(s) before main scenario"
66
+
67
+ pre_scenario_ids.each_with_index do |pre_scenario_id, index|
68
+ pre_scenario_data = @all_scenarios[pre_scenario_id]
69
+ unless pre_scenario_data
70
+ log "ERROR: Pre-scenario #{pre_scenario_id} not found in scenarios list"
71
+ send_main_scenario_verdict('PRE_SCENARIO_ERROR', "Pre-scenario not found: #{pre_scenario_id}")
72
+ stop_node_process
73
+ upload_video
74
+ cleanup_temp_dir
75
+ return 'ERROR'
76
+ end
77
+
78
+ pre_scenario_name = pre_scenario_data['name'] || pre_scenario_id
79
+ log "Running pre-scenario #{index + 1}/#{pre_scenario_ids.length}: #{pre_scenario_name}"
80
+
81
+ # Use a composite scenario_id for pre-scenarios to avoid collision with standalone runs
82
+ # Format: "pre_{main_scenario_id}_{pre_scenario_id}" ensures uniqueness per run
83
+ composite_scenario_id = "pre_#{@scenario_id}_#{pre_scenario_id}"
84
+
85
+ result = run_single_scenario(
86
+ scenario_id: composite_scenario_id,
87
+ scenario_data: pre_scenario_data,
88
+ pre_scenario_for_id: @scenario_id,
89
+ execution_order: index + 1
90
+ )
91
+
92
+ if result[:verdict] == 'FAIL'
93
+ log "Pre-scenario '#{pre_scenario_name}' FAILED - skipping main scenario"
94
+ send_main_scenario_verdict('PRE_SCENARIO_FAIL', "Pre-scenario '#{pre_scenario_name}' failed")
95
+ stop_node_process
96
+ upload_video
97
+ cleanup_temp_dir
98
+ return 'FAIL'
99
+ elsif result[:verdict] == 'ERROR'
100
+ log "Pre-scenario '#{pre_scenario_name}' ERROR - skipping main scenario"
101
+ send_main_scenario_verdict('PRE_SCENARIO_ERROR', "Pre-scenario '#{pre_scenario_name}' errored: #{result[:reason]}")
102
+ stop_node_process
103
+ upload_video
104
+ cleanup_temp_dir
105
+ return 'ERROR'
106
+ end
107
+
108
+ log "Pre-scenario '#{pre_scenario_name}' PASSED"
109
+ end
110
+
111
+ log "All pre-scenarios passed, running main scenario"
112
+ end
113
+
114
+ # Run the main scenario
115
+ result = run_single_scenario(
116
+ scenario_id: @scenario_id,
117
+ scenario_data: @scenario,
118
+ pre_scenario_for_id: nil,
119
+ execution_order: 0
120
+ )
121
+
122
+ stop_node_process
123
+ upload_video
124
+ cleanup_temp_dir
125
+
126
+ result[:verdict]
127
+ rescue StepClient::StepError => e
128
+ send_client_verdict_if_needed('ERROR', "Server error: #{e.message}")
129
+ stop_node_process
130
+ upload_video
131
+ cleanup_temp_dir
132
+ 'ERROR'
133
+ rescue StandardError => e
134
+ send_client_verdict_if_needed('ERROR', e.message)
135
+ stop_node_process
136
+ upload_video
137
+ cleanup_temp_dir
138
+ 'ERROR'
139
+ end
140
+
141
+ # Run a single scenario (either pre-scenario or main scenario)
142
+ # Returns { verdict: 'PASS'|'FAIL'|'ERROR', reason: String }
143
+ def run_single_scenario(scenario_id:, scenario_data:, pre_scenario_for_id:, execution_order:)
144
+ scenario_name = scenario_data['name'] || scenario_id
145
+ base_url = scenario_data['base_url']
146
+
147
+ # Navigate to scenario's base URL
148
+ initial_state = send_to_node('navigate', { url: base_url })
149
+ current_screenshot_base64 = initial_state.dig('result', 'screenshot_base64')
150
+ interactive_elements = initial_state.dig('result', 'elements') || []
60
151
  interactive_elements_by_id = build_elements_by_id(interactive_elements)
61
152
  previous_action = nil
62
153
  action_result = nil
63
- final_verdict = 'ERROR' # Default to ERROR if no verdict received
154
+
155
+ # Reset credentials and max_steps for each scenario (each has its own)
156
+ scenario_credentials = {}
157
+ scenario_max_steps = nil
64
158
 
65
159
  loop do
66
160
  # Get next action from server
67
161
  step_result = @step_client.submit_step(
68
- scenario_id: @scenario_id,
162
+ scenario_id: scenario_id,
69
163
  screenshot_base64: current_screenshot_base64,
70
164
  elements: interactive_elements,
71
165
  previous_action: previous_action,
72
- action_result: action_result
166
+ action_result: action_result,
167
+ pre_scenario_for_id: pre_scenario_for_id,
168
+ execution_order: execution_order
73
169
  )
74
170
 
75
171
  # 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
172
  if step_result[:credentials] && !step_result[:credentials].empty?
78
- @credentials = step_result[:credentials]
173
+ scenario_credentials = step_result[:credentials]
174
+ @credentials = scenario_credentials # Set for credential substitution
79
175
  end
80
- @max_steps ||= step_result[:max_steps]
176
+ scenario_max_steps ||= step_result[:max_steps]
81
177
 
82
178
  action = step_result[:action]
83
179
 
@@ -92,12 +188,11 @@ class Runner
92
188
  end
93
189
  end
94
190
 
95
- # Log action with element details
96
- safe_puts " [Step #{step_result[:step_number]}] #{action['action']}: #{action.reject { |k, _| k == 'action' }.to_json}"
191
+ # Log action with element details (using scenario_name for context)
192
+ puts "[#{scenario_name}] [Step #{step_result[:step_number]}] #{action['action']}: #{action.reject { |k, _| k == 'action' }.to_json}"
97
193
 
98
194
  if action['action'] == 'verdict'
99
- final_verdict = action['status'] || 'ERROR'
100
- break
195
+ return { verdict: action['status'], reason: action['reason'] }
101
196
  end
102
197
 
103
198
  # Skip browser execution for meta actions
@@ -110,8 +205,6 @@ class Runner
110
205
  result = execute_action(action)
111
206
 
112
207
  # 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
208
  if result[:screenshot_base64]
116
209
  current_screenshot_base64 = result[:screenshot_base64]
117
210
  end
@@ -124,35 +217,36 @@ class Runner
124
217
  action_result = result[:result]
125
218
 
126
219
  # 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
220
  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
221
+ if scenario_max_steps && step_number >= scenario_max_steps
222
+ puts "[#{scenario_name}] Step limit exceeded (#{scenario_max_steps})."
223
+ @step_client.set_verdict(
224
+ scenario_id: scenario_id,
225
+ status: 'ERROR',
226
+ reason: "Step limit exceeded (#{scenario_max_steps})."
227
+ )
228
+ return { verdict: 'ERROR', reason: "Step limit exceeded (#{scenario_max_steps})" }
134
229
  end
135
230
 
136
231
  # Check if server says we should stop
137
- break unless step_result[:continue]
232
+ unless step_result[:continue]
233
+ return { verdict: 'ERROR', reason: 'Server stopped scenario unexpectedly' }
234
+ end
138
235
  end
236
+ end
139
237
 
140
- stop_node_process
141
- upload_video
142
- cleanup_temp_dir
143
- final_verdict
238
+ # Send a verdict for the main scenario when pre-scenarios fail
239
+ def send_main_scenario_verdict(status, reason)
240
+ return unless @step_client
241
+
242
+ log "Setting main scenario verdict to #{status}: #{reason}"
243
+ @step_client.set_verdict(
244
+ scenario_id: @scenario_id,
245
+ status: status,
246
+ reason: reason
247
+ )
144
248
  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'
249
+ log "Warning: Failed to send main scenario verdict: #{e.message}"
156
250
  end
157
251
 
158
252
  private
@@ -163,12 +257,13 @@ class Runner
163
257
  {
164
258
  'id' => data['id'] || data[:id],
165
259
  'name' => data['name'] || data[:name],
166
- 'base_url' => data['base_url'] || data[:base_url]
260
+ 'base_url' => data['base_url'] || data[:base_url],
261
+ 'pre_scenario_ids' => data['pre_scenario_ids'] || data[:pre_scenario_ids] || []
167
262
  }
168
263
  end
169
264
 
170
265
  def start_node_process
171
- node_script = File.join(SPECSAGE_HOME, 'lib', 'browser.js')
266
+ node_script = File.join(__dir__, 'browser.js')
172
267
  raise "browser.js not found at #{node_script}" unless File.exist?(node_script)
173
268
 
174
269
  # Create unique temp directory for this runner's video recording
@@ -239,6 +334,17 @@ class Runner
239
334
  video_path = response&.dig('result', 'video_path')
240
335
  if video_path
241
336
  if File.exist?(video_path)
337
+ # Playwright WebM files lack seek metadata (Cues), so Chrome can't scrub them.
338
+ # Remux with ffmpeg to add the seek index without re-encoding.
339
+ remuxed_path = video_path.sub(/\.webm$/, '_remuxed.webm')
340
+ if system('ffmpeg', '-i', video_path, '-c', 'copy', remuxed_path, '-y', '-loglevel', 'error')
341
+ log "Video remuxed for seekability: #{remuxed_path}"
342
+ File.delete(video_path) rescue nil
343
+ video_path = remuxed_path
344
+ else
345
+ log "Warning: ffmpeg remux failed, using original video (scrubbing may not work)"
346
+ end
347
+
242
348
  @video_data = File.binread(video_path)
243
349
  log "Video captured: #{video_path} (#{@video_data&.bytesize} bytes)"
244
350
  File.delete(video_path) rescue nil
@@ -361,16 +467,6 @@ class Runner
361
467
  end
362
468
  end
363
469
 
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
470
  def execute_action(action)
375
471
  case action['action']
376
472
  when 'navigate'
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # DO NOT EDIT packages/cli/lib/step_client.rb - it is copied from this file during npm publish.
4
+ # See bin/publish_npm_package for details.
5
+
3
6
  require "net/http"
4
7
  require "uri"
5
8
  require "json"
@@ -41,15 +44,18 @@ class StepClient
41
44
 
42
45
  # Submit a step to the server
43
46
  # Returns: { action: Hash, step_number: Integer, continue: Boolean }
44
- def submit_step(scenario_id:, screenshot_base64:, elements:, previous_action: nil, action_result: nil)
47
+ def submit_step(scenario_id:, screenshot_base64:, elements:, previous_action: nil, action_result: nil,
48
+ pre_scenario_for_id: nil, execution_order: 0)
45
49
  body = {
46
50
  scenario_id: scenario_id,
47
51
  screenshot_base64: screenshot_base64,
48
- elements: elements
52
+ elements: elements,
53
+ execution_order: execution_order
49
54
  }
50
55
 
51
56
  body[:previous_action] = previous_action if previous_action
52
57
  body[:action_result] = action_result if action_result
58
+ body[:pre_scenario_for_id] = pre_scenario_for_id if pre_scenario_for_id
53
59
 
54
60
  response = post("/api/runs/#{@server_run_id}/step", body)
55
61
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specsage/cli",
3
- "version": "0.1.9",
3
+ "version": "0.1.12",
4
4
  "description": "SpecSage CLI - AI-powered end-to-end testing automation (Node wrapper for Ruby CLI)",
5
5
  "type": "module",
6
6
  "bin": {