@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 +6 -9
- package/lib/results_uploader.rb +0 -1
- package/lib/runner.rb +140 -54
- 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
|
|
@@ -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
|
-
|
|
146
|
+
all_scenarios: all_scenarios
|
|
150
147
|
)
|
|
151
148
|
|
|
152
149
|
verdict = runner.run
|
package/lib/results_uploader.rb
CHANGED
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
|
-
#
|
|
25
|
-
|
|
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: @
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
166
|
+
scenario_credentials = step_result[:credentials]
|
|
167
|
+
@credentials = scenario_credentials # Set for credential substitution
|
|
82
168
|
end
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
225
|
+
unless step_result[:continue]
|
|
226
|
+
return { verdict: 'ERROR', reason: 'Server stopped scenario unexpectedly' }
|
|
227
|
+
end
|
|
141
228
|
end
|
|
229
|
+
end
|
|
142
230
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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(
|
|
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'
|
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
|
|