@specsage/cli 0.1.0
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/bin/specsage.js +36 -0
- package/lib/browser.js +809 -0
- package/lib/cli.rb +140 -0
- package/lib/dialogs.js +106 -0
- package/lib/results_uploader.rb +133 -0
- package/lib/runner.rb +613 -0
- package/lib/step_client.rb +121 -0
- package/package.json +33 -0
package/lib/runner.rb
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'timeout'
|
|
6
|
+
require 'fileutils'
|
|
7
|
+
|
|
8
|
+
# Determine SpecSage home directory for locating resources
|
|
9
|
+
SPECSAGE_HOME ||= File.expand_path('..', __dir__)
|
|
10
|
+
|
|
11
|
+
require_relative 'step_client'
|
|
12
|
+
|
|
13
|
+
class Runner
|
|
14
|
+
NODE_IO_TIMEOUT_SECONDS = 30
|
|
15
|
+
NODE_SHUTDOWN_TIMEOUT_SECONDS = 45
|
|
16
|
+
|
|
17
|
+
BROWSER_ACTIONS = %w[navigate click select keypress wait scroll accept_dialog dismiss_dialog].freeze
|
|
18
|
+
|
|
19
|
+
# Pattern for safe path segment: alphanumeric, underscore, hyphen only
|
|
20
|
+
# Prevents directory traversal, special chars, and filesystem issues
|
|
21
|
+
SAFE_PATH_SEGMENT = /\A[a-zA-Z0-9_-]+\z/
|
|
22
|
+
|
|
23
|
+
# Initialize runner with scenario data from server
|
|
24
|
+
def initialize(scenario_data, visible: false, record: false, publisher: nil, server_run_id: nil)
|
|
25
|
+
@scenario = normalize_scenario_data(scenario_data)
|
|
26
|
+
@scenario_id = @scenario['id']
|
|
27
|
+
@scenario_name = @scenario['name'] || @scenario['id'] || 'unnamed'
|
|
28
|
+
@visible = visible
|
|
29
|
+
@record = record
|
|
30
|
+
@video_data = nil # Binary video data
|
|
31
|
+
@node_stdin = nil
|
|
32
|
+
@node_stdout = nil
|
|
33
|
+
@node_wait_thread = nil
|
|
34
|
+
@next_request_id = 1
|
|
35
|
+
@node_channel_poisoned = false
|
|
36
|
+
@publisher = publisher
|
|
37
|
+
@step_client = nil
|
|
38
|
+
@server_run_id = server_run_id
|
|
39
|
+
@credentials = {} # Credentials received from server { "NAME" => "value" }
|
|
40
|
+
@max_steps = nil # Max browser actions allowed, received from server on first step
|
|
41
|
+
@temp_dir = nil # Unique temp directory for this runner's video recording
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def run
|
|
45
|
+
log "Starting scenario run"
|
|
46
|
+
|
|
47
|
+
raise ArgumentError, 'server_run_id is required' unless @server_run_id
|
|
48
|
+
|
|
49
|
+
@step_client = StepClient.new(
|
|
50
|
+
base_url: @publisher.base_url,
|
|
51
|
+
server_run_id: @server_run_id,
|
|
52
|
+
api_key: @publisher.api_key
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
start_node_process
|
|
56
|
+
initial_state = navigate_to_base_url
|
|
57
|
+
|
|
58
|
+
current_screenshot_base64 = initial_state[:screenshot_base64]
|
|
59
|
+
interactive_elements = initial_state[:elements]
|
|
60
|
+
interactive_elements_by_id = build_elements_by_id(interactive_elements)
|
|
61
|
+
previous_action = nil
|
|
62
|
+
action_result = nil
|
|
63
|
+
|
|
64
|
+
loop do
|
|
65
|
+
# Get next action from server
|
|
66
|
+
step_result = @step_client.submit_step(
|
|
67
|
+
scenario_id: @scenario_id,
|
|
68
|
+
screenshot_base64: current_screenshot_base64,
|
|
69
|
+
elements: interactive_elements,
|
|
70
|
+
previous_action: previous_action,
|
|
71
|
+
action_result: action_result
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Store credentials and max_steps from server on first step only
|
|
75
|
+
# Server sends these once at scenario start, runner caches them for the duration
|
|
76
|
+
if step_result[:credentials] && !step_result[:credentials].empty?
|
|
77
|
+
@credentials = step_result[:credentials]
|
|
78
|
+
end
|
|
79
|
+
@max_steps ||= step_result[:max_steps]
|
|
80
|
+
|
|
81
|
+
action = step_result[:action]
|
|
82
|
+
|
|
83
|
+
# Enrich action with element info for logging and storage
|
|
84
|
+
if action['element_id'] && interactive_elements_by_id
|
|
85
|
+
element = interactive_elements_by_id[action['element_id']]
|
|
86
|
+
if element
|
|
87
|
+
action['element_name'] = element_display_name(element)
|
|
88
|
+
action['type'] = element['type']
|
|
89
|
+
action['role'] = element['role']
|
|
90
|
+
action['visible_text'] = element['visible_text']
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Log action with element details
|
|
95
|
+
safe_puts " [Step #{step_result[:step_number]}] #{action['action']}: #{action.reject { |k, _| k == 'action' }.to_json}"
|
|
96
|
+
|
|
97
|
+
break if action['action'] == 'verdict'
|
|
98
|
+
|
|
99
|
+
# Skip browser execution for meta actions
|
|
100
|
+
unless BROWSER_ACTIONS.include?(action['action'])
|
|
101
|
+
previous_action = action
|
|
102
|
+
action_result = "Meta action: #{action['action']}"
|
|
103
|
+
next
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
result = execute_action(action)
|
|
107
|
+
|
|
108
|
+
# Update state from action result
|
|
109
|
+
# Note: screenshot may be nil if a dialog is blocking (can't screenshot while dialog is up)
|
|
110
|
+
# but we still need to update elements (which will contain the DIALOG pseudo-element)
|
|
111
|
+
if result[:screenshot_base64]
|
|
112
|
+
current_screenshot_base64 = result[:screenshot_base64]
|
|
113
|
+
end
|
|
114
|
+
if result[:elements]
|
|
115
|
+
interactive_elements = result[:elements]
|
|
116
|
+
interactive_elements_by_id = build_elements_by_id(interactive_elements)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
previous_action = action
|
|
120
|
+
action_result = result[:result]
|
|
121
|
+
|
|
122
|
+
# Check if step count has reached max_steps limit
|
|
123
|
+
# This check happens AFTER executing the action, so max_steps=4 means 4 actions execute
|
|
124
|
+
step_number = step_result[:step_number] || 0
|
|
125
|
+
if @max_steps && step_number >= @max_steps
|
|
126
|
+
log "Step limit exceeded (#{@max_steps})."
|
|
127
|
+
send_client_verdict_if_needed('ERROR', "Step limit exceeded (#{@max_steps}).")
|
|
128
|
+
break
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Check if server says we should stop
|
|
132
|
+
break unless step_result[:continue]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
stop_node_process
|
|
136
|
+
upload_video
|
|
137
|
+
cleanup_temp_dir
|
|
138
|
+
rescue StepClient::StepError => e
|
|
139
|
+
send_client_verdict_if_needed('ERROR', "Server error: #{e.message}")
|
|
140
|
+
stop_node_process
|
|
141
|
+
upload_video
|
|
142
|
+
cleanup_temp_dir
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
send_client_verdict_if_needed('ERROR', e.message)
|
|
145
|
+
stop_node_process
|
|
146
|
+
upload_video
|
|
147
|
+
cleanup_temp_dir
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
# Normalize scenario data from API format (string keys) to internal format
|
|
153
|
+
def normalize_scenario_data(data)
|
|
154
|
+
# API returns string keys, ensure consistent access
|
|
155
|
+
{
|
|
156
|
+
'id' => data['id'] || data[:id],
|
|
157
|
+
'name' => data['name'] || data[:name],
|
|
158
|
+
'base_url' => data['base_url'] || data[:base_url]
|
|
159
|
+
}
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def start_node_process
|
|
163
|
+
node_script = File.join(SPECSAGE_HOME, 'lib', 'browser.js')
|
|
164
|
+
raise "browser.js not found at #{node_script}" unless File.exist?(node_script)
|
|
165
|
+
|
|
166
|
+
# Create unique temp directory for this runner's video recording
|
|
167
|
+
if @record
|
|
168
|
+
@temp_dir = build_safe_temp_dir
|
|
169
|
+
FileUtils.mkdir_p(@temp_dir)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
args = ['node', node_script]
|
|
173
|
+
args << '--visible' if @visible
|
|
174
|
+
args << '--record' if @record
|
|
175
|
+
args.push('--temp-dir', @temp_dir) if @temp_dir
|
|
176
|
+
@node_stdin, @node_stdout, @node_stderr, @node_wait_thread = Open3.popen3(*args)
|
|
177
|
+
|
|
178
|
+
# Wait for ready signal from browser.js
|
|
179
|
+
wait_for_ready_signal
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def wait_for_ready_signal(timeout: NODE_IO_TIMEOUT_SECONDS)
|
|
183
|
+
response = Timeout.timeout(timeout) do
|
|
184
|
+
line = @node_stdout.gets
|
|
185
|
+
raise 'browser.js closed stdout before sending ready signal' unless line
|
|
186
|
+
|
|
187
|
+
JSON.parse(line)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
unless response['status'] == 'ready'
|
|
191
|
+
raise "Expected ready signal from browser.js, got: #{response.inspect}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Drain any stderr output from browser startup (e.g., video recording status)
|
|
195
|
+
drain_node_stderr
|
|
196
|
+
rescue Timeout::Error
|
|
197
|
+
raise "browser.js startup timed out after #{timeout}s waiting for ready signal"
|
|
198
|
+
rescue JSON::ParserError => e
|
|
199
|
+
raise "Protocol error during startup: Invalid JSON from browser.js: #{e.message}"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def drain_node_stderr
|
|
203
|
+
return unless @node_stderr
|
|
204
|
+
|
|
205
|
+
loop do
|
|
206
|
+
line = @node_stderr.read_nonblock(4096)
|
|
207
|
+
break if line.nil? || line.empty?
|
|
208
|
+
line.each_line { |l| log "Node: #{l.strip}" unless l.strip.empty? }
|
|
209
|
+
end
|
|
210
|
+
rescue IO::WaitReadable, EOFError
|
|
211
|
+
# No more data available
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def stop_node_process
|
|
215
|
+
return unless @node_stdin || @node_wait_thread
|
|
216
|
+
|
|
217
|
+
pid = @node_wait_thread&.pid
|
|
218
|
+
|
|
219
|
+
# Attempt graceful shutdown with protocol-compliant quit (skip if channel poisoned)
|
|
220
|
+
unless @node_channel_poisoned
|
|
221
|
+
begin
|
|
222
|
+
Timeout.timeout(NODE_SHUTDOWN_TIMEOUT_SECONDS) do
|
|
223
|
+
request_id = @next_request_id
|
|
224
|
+
request = { request_id: request_id, command: 'quit', params: {} }
|
|
225
|
+
@node_stdin.puts(request.to_json)
|
|
226
|
+
@node_stdin.flush
|
|
227
|
+
response_line = @node_stdout.gets
|
|
228
|
+
if response_line
|
|
229
|
+
begin
|
|
230
|
+
response = JSON.parse(response_line)
|
|
231
|
+
video_path = response&.dig('result', 'video_path')
|
|
232
|
+
if video_path
|
|
233
|
+
if File.exist?(video_path)
|
|
234
|
+
@video_data = File.binread(video_path)
|
|
235
|
+
log "Video captured: #{video_path} (#{@video_data&.bytesize} bytes)"
|
|
236
|
+
File.delete(video_path) rescue nil
|
|
237
|
+
else
|
|
238
|
+
log "Warning: Video path returned but file does not exist: #{video_path}"
|
|
239
|
+
# List contents of temp directory for debugging
|
|
240
|
+
if @temp_dir && Dir.exist?(@temp_dir)
|
|
241
|
+
files = Dir.entries(@temp_dir).reject { |f| f.start_with?('.') }
|
|
242
|
+
log "Temp dir contents: #{files.inspect}"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
else
|
|
246
|
+
log "Warning: No video path in quit response (recording may not have been enabled)"
|
|
247
|
+
end
|
|
248
|
+
rescue JSON::ParserError => e
|
|
249
|
+
log "Warning: Failed to parse quit response: #{e.message}"
|
|
250
|
+
end
|
|
251
|
+
else
|
|
252
|
+
log "Warning: No response from Node process on quit"
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
rescue Timeout::Error => e
|
|
256
|
+
log "Warning: Timeout waiting for Node shutdown: #{e.message}"
|
|
257
|
+
rescue StandardError => e
|
|
258
|
+
log "Warning: Error during Node shutdown: #{e.class}: #{e.message}"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Read any remaining stderr output from Node process for debugging
|
|
263
|
+
drain_node_stderr
|
|
264
|
+
|
|
265
|
+
# Close IO streams
|
|
266
|
+
@node_stdin&.close rescue nil
|
|
267
|
+
@node_stdout&.close rescue nil
|
|
268
|
+
@node_stderr&.close rescue nil
|
|
269
|
+
|
|
270
|
+
# Force kill the process if still running
|
|
271
|
+
if pid
|
|
272
|
+
begin
|
|
273
|
+
Process.kill(0, pid) # Check if process exists
|
|
274
|
+
Process.kill('TERM', pid)
|
|
275
|
+
# Wait briefly for termination
|
|
276
|
+
Timeout.timeout(2) { @node_wait_thread&.join }
|
|
277
|
+
rescue Errno::ESRCH
|
|
278
|
+
# Process already dead, OK
|
|
279
|
+
rescue Timeout::Error
|
|
280
|
+
# Force kill if TERM didn't work
|
|
281
|
+
Process.kill('KILL', pid) rescue nil
|
|
282
|
+
rescue StandardError
|
|
283
|
+
# Ignore other errors during cleanup
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
@node_wait_thread = nil
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def send_to_node(command, params = {}, timeout: NODE_IO_TIMEOUT_SECONDS)
|
|
291
|
+
raise 'Protocol error: Node channel is poisoned, cannot send' if @node_channel_poisoned
|
|
292
|
+
|
|
293
|
+
request_id = @next_request_id
|
|
294
|
+
request = { request_id: request_id, command: command, params: params }
|
|
295
|
+
|
|
296
|
+
begin
|
|
297
|
+
response = Timeout.timeout(timeout) do
|
|
298
|
+
@node_stdin.puts(request.to_json)
|
|
299
|
+
@node_stdin.flush
|
|
300
|
+
response_line = @node_stdout.gets
|
|
301
|
+
raise 'No response from Node process' unless response_line
|
|
302
|
+
|
|
303
|
+
JSON.parse(response_line)
|
|
304
|
+
end
|
|
305
|
+
rescue Timeout::Error
|
|
306
|
+
poison_node_channel!
|
|
307
|
+
raise "Node process timed out after #{timeout}s waiting for response"
|
|
308
|
+
rescue JSON::ParserError => e
|
|
309
|
+
poison_node_channel!
|
|
310
|
+
raise "Protocol error: Invalid JSON from Node: #{e.message}"
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Validate response envelope per docs/messages.md
|
|
314
|
+
validate_node_response!(response, request_id)
|
|
315
|
+
|
|
316
|
+
# Increment request_id only after successful validation
|
|
317
|
+
@next_request_id += 1
|
|
318
|
+
|
|
319
|
+
response
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def poison_node_channel!
|
|
323
|
+
@node_channel_poisoned = true
|
|
324
|
+
# Immediately terminate the Node process
|
|
325
|
+
stop_node_process
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def validate_node_response!(response, expected_request_id)
|
|
329
|
+
# Check required keys
|
|
330
|
+
unless response.key?('request_id')
|
|
331
|
+
poison_node_channel!
|
|
332
|
+
raise 'Protocol error: missing request_id in Node response'
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
unless response.key?('ok') && response.key?('result')
|
|
336
|
+
poison_node_channel!
|
|
337
|
+
raise 'Protocol error: missing ok or result in Node response'
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Check request_id match (null from Node means parse error, treat as fatal)
|
|
341
|
+
response_id = response['request_id']
|
|
342
|
+
if response_id.nil? || response_id != expected_request_id
|
|
343
|
+
poison_node_channel!
|
|
344
|
+
raise "Protocol error: request_id mismatch - expected #{expected_request_id}, got #{response_id.inspect}"
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Check for error response
|
|
348
|
+
unless response['ok']
|
|
349
|
+
error = response['error'] || {}
|
|
350
|
+
error_code = error['code'] || 'UNKNOWN'
|
|
351
|
+
error_message = error['message'] || 'Unknown error from Node'
|
|
352
|
+
raise "Node error [#{error_code}]: #{error_message}"
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def navigate_to_base_url
|
|
357
|
+
base_url = @scenario['base_url']
|
|
358
|
+
raise 'base_url not specified in scenario file' unless base_url
|
|
359
|
+
|
|
360
|
+
response = send_to_node('navigate', { url: base_url })
|
|
361
|
+
screenshot_base64 = response.dig('result', 'screenshot_base64')
|
|
362
|
+
elements = response.dig('result', 'elements') || []
|
|
363
|
+
{ screenshot_base64: screenshot_base64, elements: elements }
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def execute_action(action)
|
|
367
|
+
case action['action']
|
|
368
|
+
when 'navigate'
|
|
369
|
+
# Substitute credentials in URL (e.g., https://<<API_KEY>>@api.example.com)
|
|
370
|
+
url = action['url']
|
|
371
|
+
display_url = url # For logging (shows placeholders, not actual values)
|
|
372
|
+
url = substitute_credentials(url) if contains_credential_placeholder?(url)
|
|
373
|
+
|
|
374
|
+
response = send_to_node('navigate', { url: url })
|
|
375
|
+
screenshot_base64 = response.dig('result', 'screenshot_base64')
|
|
376
|
+
elements = response.dig('result', 'elements') || []
|
|
377
|
+
{ result: "Navigated to #{display_url}", screenshot_base64: screenshot_base64, elements: elements }
|
|
378
|
+
|
|
379
|
+
when 'click'
|
|
380
|
+
element_id = action['element_id']
|
|
381
|
+
response = send_to_node('click_element', { element_id: element_id })
|
|
382
|
+
screenshot_base64 = response.dig('result', 'screenshot_base64')
|
|
383
|
+
elements = response.dig('result', 'elements') || []
|
|
384
|
+
{ result: "Clicked element #{element_id}", screenshot_base64: screenshot_base64, elements: elements }
|
|
385
|
+
|
|
386
|
+
when 'select'
|
|
387
|
+
element_id = action['element_id']
|
|
388
|
+
value = action['value']
|
|
389
|
+
display_value = value # For logging (shows placeholders, not actual values)
|
|
390
|
+
value = substitute_credentials(value) if contains_credential_placeholder?(value)
|
|
391
|
+
|
|
392
|
+
response = send_to_node('select_option', { element_id: element_id, value: value })
|
|
393
|
+
screenshot_base64 = response.dig('result', 'screenshot_base64')
|
|
394
|
+
elements = response.dig('result', 'elements') || []
|
|
395
|
+
{ result: "Selected '#{display_value}' in element #{element_id}", screenshot_base64: screenshot_base64, elements: elements }
|
|
396
|
+
|
|
397
|
+
when 'keypress'
|
|
398
|
+
# Substitute credential placeholders at the last moment before browser execution
|
|
399
|
+
# Supports inline placeholders: <<USERNAME>>@example.com, <<USER>>:<<PASS>>, etc.
|
|
400
|
+
keys = action['keys']
|
|
401
|
+
display_keys = keys # For logging (shows placeholders, not actual values)
|
|
402
|
+
# Skip credential substitution for special key combos like ctrl+a
|
|
403
|
+
keys = substitute_credentials(keys) if contains_credential_placeholder?(keys) && !special_key_combo?(keys)
|
|
404
|
+
|
|
405
|
+
response = send_to_node('keypress', { keys: keys })
|
|
406
|
+
screenshot_base64 = response.dig('result', 'screenshot_base64')
|
|
407
|
+
elements = response.dig('result', 'elements') || []
|
|
408
|
+
{ result: "Pressed keys: #{display_keys}", screenshot_base64: screenshot_base64, elements: elements }
|
|
409
|
+
|
|
410
|
+
when 'wait'
|
|
411
|
+
response = send_to_node('wait', { ms: action['ms'] })
|
|
412
|
+
screenshot_base64 = response.dig('result', 'screenshot_base64')
|
|
413
|
+
elements = response.dig('result', 'elements') || []
|
|
414
|
+
{ result: "Waited #{action['ms']}ms", screenshot_base64: screenshot_base64, elements: elements }
|
|
415
|
+
|
|
416
|
+
when 'scroll'
|
|
417
|
+
response = send_to_node('scroll', { direction: action['direction'] })
|
|
418
|
+
screenshot_base64 = response.dig('result', 'screenshot_base64')
|
|
419
|
+
elements = response.dig('result', 'elements') || []
|
|
420
|
+
{ result: "Scrolled #{action['direction']}", screenshot_base64: screenshot_base64, elements: elements }
|
|
421
|
+
|
|
422
|
+
when 'accept_dialog'
|
|
423
|
+
value = action['value'] # Optional, for prompt dialogs
|
|
424
|
+
response = send_to_node('accept_dialog', { value: value })
|
|
425
|
+
screenshot_base64 = response.dig('result', 'screenshot_base64')
|
|
426
|
+
elements = response.dig('result', 'elements') || []
|
|
427
|
+
{ result: "Accepted dialog#{value ? " with value '#{value}'" : ''}", screenshot_base64: screenshot_base64, elements: elements }
|
|
428
|
+
|
|
429
|
+
when 'dismiss_dialog'
|
|
430
|
+
response = send_to_node('dismiss_dialog', {})
|
|
431
|
+
screenshot_base64 = response.dig('result', 'screenshot_base64')
|
|
432
|
+
elements = response.dig('result', 'elements') || []
|
|
433
|
+
{ result: "Dismissed dialog", screenshot_base64: screenshot_base64, elements: elements }
|
|
434
|
+
|
|
435
|
+
else
|
|
436
|
+
{ result: "Unknown action: #{action['action']}", screenshot_base64: nil, elements: nil }
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def build_elements_by_id(elements)
|
|
441
|
+
return {} unless elements
|
|
442
|
+
|
|
443
|
+
elements.each_with_object({}) { |e, h| h[e['id']] = e }
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Extract a human-readable display name from an element.
|
|
447
|
+
# SECURITY: Returns sanitized text safe for storage/display. The returned value
|
|
448
|
+
# originates from browser DOM and should never be marked html_safe in templates.
|
|
449
|
+
def element_display_name(element)
|
|
450
|
+
name = element['accessible_name'].to_s.strip
|
|
451
|
+
return sanitize_display_text(name) unless name.empty?
|
|
452
|
+
|
|
453
|
+
name = element['visible_text'].to_s.strip
|
|
454
|
+
return sanitize_display_text(name) unless name.empty?
|
|
455
|
+
|
|
456
|
+
# Fallback: type (e.g., "button", "input", "link") - trusted internal value
|
|
457
|
+
element['type']
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Sanitize untrusted display text: strip control characters, enforce max length.
|
|
461
|
+
# Mirrors Document::Test::Step.sanitize_display_text for use in runner context.
|
|
462
|
+
MAX_DISPLAY_TEXT_LENGTH = 500
|
|
463
|
+
|
|
464
|
+
def sanitize_display_text(text)
|
|
465
|
+
return nil if text.nil?
|
|
466
|
+
|
|
467
|
+
text.to_s
|
|
468
|
+
.gsub(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/, '') # Strip control chars (keep \t, \n, \r)
|
|
469
|
+
.strip
|
|
470
|
+
.slice(0, MAX_DISPLAY_TEXT_LENGTH)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def upload_video
|
|
474
|
+
unless @video_data
|
|
475
|
+
log "No video data to upload (recording may not have been enabled or failed)"
|
|
476
|
+
return
|
|
477
|
+
end
|
|
478
|
+
unless @step_client
|
|
479
|
+
log "Warning: No step client available for video upload"
|
|
480
|
+
return
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
log "Uploading video (#{@video_data.bytesize} bytes)..."
|
|
484
|
+
@step_client.upload_video(scenario_id: @scenario_id, video_data: @video_data)
|
|
485
|
+
log "Video uploaded successfully."
|
|
486
|
+
rescue StepClient::StepError => e
|
|
487
|
+
log "Warning: Failed to upload video: #{e.message}"
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def cleanup_temp_dir
|
|
491
|
+
return unless @temp_dir && Dir.exist?(@temp_dir)
|
|
492
|
+
|
|
493
|
+
# Safety check: only delete if temp_dir is within expected base directory
|
|
494
|
+
base_tmp = File.join(SPECSAGE_HOME, 'tmp')
|
|
495
|
+
unless @temp_dir.start_with?(base_tmp + File::SEPARATOR)
|
|
496
|
+
log "Warning: Refusing to delete temp_dir outside expected location: #{@temp_dir}"
|
|
497
|
+
return
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
FileUtils.rm_rf(@temp_dir)
|
|
501
|
+
# Also clean up parent run directory if empty
|
|
502
|
+
run_dir = File.dirname(@temp_dir)
|
|
503
|
+
if Dir.exist?(run_dir) && Dir.empty?(run_dir) && run_dir.start_with?(base_tmp + File::SEPARATOR)
|
|
504
|
+
FileUtils.rmdir(run_dir)
|
|
505
|
+
end
|
|
506
|
+
rescue StandardError
|
|
507
|
+
# Ignore cleanup errors
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# --- Safe path handling ---
|
|
511
|
+
|
|
512
|
+
# Build a safe temp directory path, validating that IDs are safe path segments
|
|
513
|
+
def build_safe_temp_dir
|
|
514
|
+
validate_path_segment!(@server_run_id, 'server_run_id')
|
|
515
|
+
validate_path_segment!(@scenario_id, 'scenario_id')
|
|
516
|
+
|
|
517
|
+
File.join(SPECSAGE_HOME, 'tmp', @server_run_id, @scenario_id)
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# Validate that a string is safe to use as a filesystem path segment
|
|
521
|
+
# Raises if the value contains path traversal chars, slashes, or unsafe characters
|
|
522
|
+
def validate_path_segment!(value, name)
|
|
523
|
+
return if value.is_a?(String) && value.match?(SAFE_PATH_SEGMENT) && value.length <= 100
|
|
524
|
+
|
|
525
|
+
raise ArgumentError, "#{name} contains unsafe characters for filesystem path: #{value.inspect}. " \
|
|
526
|
+
"Expected alphanumeric, underscore, or hyphen only."
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# Send verdict to server for client-side errors (browser crash, etc.)
|
|
530
|
+
# Normal PASS/FAIL verdicts from the LLM are already recorded server-side
|
|
531
|
+
def send_client_verdict_if_needed(status, reason)
|
|
532
|
+
return unless @step_client
|
|
533
|
+
|
|
534
|
+
log "Sending client verdict to server: #{status}"
|
|
535
|
+
@step_client.set_verdict(
|
|
536
|
+
scenario_id: @scenario_id,
|
|
537
|
+
status: status,
|
|
538
|
+
reason: reason
|
|
539
|
+
)
|
|
540
|
+
rescue StepClient::StepError => e
|
|
541
|
+
log "Warning: Failed to send verdict to server: #{e.message}"
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# --- Logging with credential protection ---
|
|
545
|
+
|
|
546
|
+
# Log with scenario name prefix for parallel execution clarity
|
|
547
|
+
def log(message)
|
|
548
|
+
guard_log_against_credential_leak(message)
|
|
549
|
+
puts "[#{@scenario_name}] #{message}"
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# Safe logging that guards against credential leaks in dev/test
|
|
553
|
+
def safe_puts(message)
|
|
554
|
+
guard_log_against_credential_leak(message)
|
|
555
|
+
puts "[#{@scenario_name}] #{message}"
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# Raise if a log message contains actual credential values
|
|
559
|
+
# Runs in ALL environments - production is where leaks matter most
|
|
560
|
+
def guard_log_against_credential_leak(message)
|
|
561
|
+
return if @credentials.nil? || @credentials.empty?
|
|
562
|
+
|
|
563
|
+
@credentials.each do |name, secret_value|
|
|
564
|
+
next if secret_value.nil? || secret_value.empty?
|
|
565
|
+
|
|
566
|
+
if message.to_s.include?(secret_value)
|
|
567
|
+
raise "SECURITY: Credential '#{name}' value leaked into log output. " \
|
|
568
|
+
"Use placeholders like <<#{name}>> in logs, not actual values."
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
# --- Credential substitution ---
|
|
574
|
+
# Substitutes <<CREDENTIAL_NAME>> placeholders in action fields.
|
|
575
|
+
# Placeholders can appear anywhere in the string (inline substitution).
|
|
576
|
+
# Only credentials in the allowlist (from server) can be substituted.
|
|
577
|
+
|
|
578
|
+
CREDENTIAL_PLACEHOLDER_PATTERN = /<<([A-Z][A-Z0-9_]*)>>/
|
|
579
|
+
|
|
580
|
+
# Special key combinations that should not be treated as credential placeholders
|
|
581
|
+
SPECIAL_KEY_COMBOS = %w[ctrl+a Ctrl+A].freeze
|
|
582
|
+
|
|
583
|
+
# Check if the value is a special key combo (e.g., ctrl+a)
|
|
584
|
+
def special_key_combo?(value)
|
|
585
|
+
SPECIAL_KEY_COMBOS.include?(value)
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# Check if the value contains any credential placeholders
|
|
589
|
+
def contains_credential_placeholder?(value)
|
|
590
|
+
return false unless value.is_a?(String)
|
|
591
|
+
|
|
592
|
+
CREDENTIAL_PLACEHOLDER_PATTERN.match?(value)
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
# Substitute all credential placeholders in a string with their actual values
|
|
596
|
+
# Raises if any placeholder references a credential not in the allowlist
|
|
597
|
+
def substitute_credentials(value)
|
|
598
|
+
return value unless value.is_a?(String)
|
|
599
|
+
|
|
600
|
+
value.gsub(CREDENTIAL_PLACEHOLDER_PATTERN) do |match|
|
|
601
|
+
name = Regexp.last_match(1)
|
|
602
|
+
|
|
603
|
+
unless @credentials.key?(name)
|
|
604
|
+
raise "Credential '#{name}' not allowed for this scenario. " \
|
|
605
|
+
"Available credentials: #{@credentials.keys.join(', ').presence || 'none'}"
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
@credentials[name]
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
require 'open3'
|