@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 +3 -0
- package/lib/cli.rb +7 -2
- package/lib/dialogs.js +3 -0
- package/lib/results_uploader.rb +0 -1
- package/lib/runner.rb +146 -50
- package/lib/step_client.rb +8 -2
- package/package.json +1 -1
package/lib/browser.js
CHANGED
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 =
|
|
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
|
package/lib/results_uploader.rb
CHANGED
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
173
|
+
scenario_credentials = step_result[:credentials]
|
|
174
|
+
@credentials = scenario_credentials # Set for credential substitution
|
|
79
175
|
end
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
232
|
+
unless step_result[:continue]
|
|
233
|
+
return { verdict: 'ERROR', reason: 'Server stopped scenario unexpectedly' }
|
|
234
|
+
end
|
|
138
235
|
end
|
|
236
|
+
end
|
|
139
237
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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(
|
|
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'
|
package/lib/step_client.rb
CHANGED
|
@@ -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
|
|