@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 +7 -2
- package/lib/results_uploader.rb +0 -1
- package/lib/runner.rb +139 -50
- package/lib/step_client.rb +5 -2
- package/package.json +1 -1
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/results_uploader.rb
CHANGED
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
166
|
+
scenario_credentials = step_result[:credentials]
|
|
167
|
+
@credentials = scenario_credentials # Set for credential substitution
|
|
79
168
|
end
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
225
|
+
unless step_result[:continue]
|
|
226
|
+
return { verdict: 'ERROR', reason: 'Server stopped scenario unexpectedly' }
|
|
227
|
+
end
|
|
138
228
|
end
|
|
229
|
+
end
|
|
139
230
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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(
|
|
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'
|
package/lib/step_client.rb
CHANGED
|
@@ -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
|
|