@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/cli.rb
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SpecSage CLI entry point for npm package
|
|
5
|
+
# This file is spawned by the Node wrapper (bin/specsage.js)
|
|
6
|
+
|
|
7
|
+
require 'optparse'
|
|
8
|
+
require 'fileutils'
|
|
9
|
+
|
|
10
|
+
# Set the package root directory
|
|
11
|
+
SPECSAGE_HOME = File.expand_path('..', __dir__)
|
|
12
|
+
|
|
13
|
+
# Add lib directory to load path
|
|
14
|
+
$LOAD_PATH.unshift(File.join(SPECSAGE_HOME, 'lib'))
|
|
15
|
+
|
|
16
|
+
require 'runner'
|
|
17
|
+
require 'results_uploader'
|
|
18
|
+
|
|
19
|
+
class SpecSageCLI
|
|
20
|
+
def initialize(args)
|
|
21
|
+
@args = args
|
|
22
|
+
@options = {
|
|
23
|
+
visible: false,
|
|
24
|
+
record: true,
|
|
25
|
+
server_url: nil,
|
|
26
|
+
ci_mode: false
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run
|
|
31
|
+
parse_options!
|
|
32
|
+
|
|
33
|
+
unless @options[:ci_mode]
|
|
34
|
+
puts "Error: --ci flag is required"
|
|
35
|
+
exit 1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
run_ci_mode
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def parse_options!
|
|
44
|
+
parser = OptionParser.new do |opts|
|
|
45
|
+
opts.banner = "Usage: specsage --ci [options]"
|
|
46
|
+
|
|
47
|
+
opts.on("--ci", "CI mode (required)") do
|
|
48
|
+
@options[:ci_mode] = true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
opts.on("-v", "--visible", "Run browser in visible mode") do
|
|
52
|
+
@options[:visible] = true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
opts.on("--no-record", "Disable video recording") do
|
|
56
|
+
@options[:record] = false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
opts.on("-s", "--server URL", "Server URL") do |url|
|
|
60
|
+
@options[:server_url] = url
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
opts.on("-h", "--help", "Show help") do
|
|
64
|
+
puts opts
|
|
65
|
+
exit 0
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
parser.parse!(@args)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def run_ci_mode
|
|
73
|
+
website = ENV['TARGET_WEBSITE_SLUG']
|
|
74
|
+
api_key = ENV['SPEC_SAGE_API_KEY']
|
|
75
|
+
|
|
76
|
+
unless website
|
|
77
|
+
puts "Error: TARGET_WEBSITE_SLUG environment variable required"
|
|
78
|
+
exit 1
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
unless api_key
|
|
82
|
+
puts "Error: SPEC_SAGE_API_KEY environment variable required"
|
|
83
|
+
exit 1
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
puts "SpecSage CI Mode"
|
|
87
|
+
puts "=" * 50
|
|
88
|
+
puts "Website: #{website}"
|
|
89
|
+
puts ""
|
|
90
|
+
|
|
91
|
+
publisher = ResultsUploader.new(
|
|
92
|
+
base_url: @options[:server_url],
|
|
93
|
+
api_key: api_key
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
begin
|
|
97
|
+
ci_run = publisher.create_ci_run(website)
|
|
98
|
+
rescue ResultsUploader::UploadError => e
|
|
99
|
+
puts "Error: #{e.message}"
|
|
100
|
+
exit 1
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
server_run_id = ci_run['server_run_id']
|
|
104
|
+
base_url = ci_run['base_url']
|
|
105
|
+
|
|
106
|
+
puts "Server run ID: #{server_run_id}"
|
|
107
|
+
puts "Base URL: #{base_url}"
|
|
108
|
+
puts ""
|
|
109
|
+
|
|
110
|
+
runner = Runner.new(
|
|
111
|
+
{ 'base_url' => base_url },
|
|
112
|
+
visible: @options[:visible],
|
|
113
|
+
record: @options[:record],
|
|
114
|
+
publisher: publisher,
|
|
115
|
+
server_run_id: server_run_id
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
verdict = runner.run
|
|
119
|
+
|
|
120
|
+
puts ""
|
|
121
|
+
puts "=" * 50
|
|
122
|
+
puts "SpecSage CI Run Complete"
|
|
123
|
+
puts "=" * 50
|
|
124
|
+
puts "Verdict: #{verdict}"
|
|
125
|
+
puts ""
|
|
126
|
+
|
|
127
|
+
case verdict
|
|
128
|
+
when 'PASS'
|
|
129
|
+
exit 0
|
|
130
|
+
when 'FAIL'
|
|
131
|
+
exit 1
|
|
132
|
+
else
|
|
133
|
+
exit 2
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
if __FILE__ == $PROGRAM_NAME
|
|
139
|
+
SpecSageCLI.new(ARGV).run
|
|
140
|
+
end
|
package/lib/dialogs.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dialog handling module for SpecSage browser automation.
|
|
3
|
+
* Captures JavaScript dialogs (alert, confirm, prompt) and exposes them
|
|
4
|
+
* to the AI agent via visual overlays and pseudo-elements.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Pending dialog state
|
|
8
|
+
let pendingDialog = null;
|
|
9
|
+
let page = null;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Set up the dialog handler on a Playwright page.
|
|
13
|
+
* Captures dialogs without auto-resolving them.
|
|
14
|
+
*
|
|
15
|
+
* IMPORTANT: When a dialog is open, Playwright blocks all page interactions
|
|
16
|
+
* (evaluate, screenshot, etc.). The dialog must be accepted/dismissed first.
|
|
17
|
+
* We store the dialog info synchronously for the AI to see in the elements list.
|
|
18
|
+
*/
|
|
19
|
+
export function setupDialogHandler(playwrightPage) {
|
|
20
|
+
page = playwrightPage;
|
|
21
|
+
|
|
22
|
+
page.on('dialog', dialog => {
|
|
23
|
+
// Store dialog info synchronously - don't await anything here
|
|
24
|
+
// because the page is blocked until the dialog is resolved
|
|
25
|
+
pendingDialog = {
|
|
26
|
+
type: dialog.type(),
|
|
27
|
+
message: dialog.message(),
|
|
28
|
+
defaultValue: dialog.defaultValue(),
|
|
29
|
+
dialog: dialog
|
|
30
|
+
};
|
|
31
|
+
// Note: Cannot inject overlay while dialog is blocking the page
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check if a dialog is currently pending.
|
|
37
|
+
*/
|
|
38
|
+
export function hasDialog() {
|
|
39
|
+
return pendingDialog !== null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get the pending dialog message (for error messages).
|
|
44
|
+
*/
|
|
45
|
+
export function getDialogInfo() {
|
|
46
|
+
if (!pendingDialog) return null;
|
|
47
|
+
return { type: pendingDialog.type, message: pendingDialog.message };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Returns a pseudo-element representing the dialog for the elements list.
|
|
52
|
+
*/
|
|
53
|
+
export function getDialogPseudoElement() {
|
|
54
|
+
if (!pendingDialog) return null;
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
id: 'DIALOG',
|
|
58
|
+
stable_key: 'dialog|pending',
|
|
59
|
+
type: 'dialog',
|
|
60
|
+
role: pendingDialog.type,
|
|
61
|
+
accessible_name: pendingDialog.message,
|
|
62
|
+
visible_text: pendingDialog.message,
|
|
63
|
+
disabled: false,
|
|
64
|
+
input_type: pendingDialog.type === 'prompt' ? 'text' : null,
|
|
65
|
+
default_value: pendingDialog.defaultValue || null,
|
|
66
|
+
bounding_box: { x: 390, y: 300, w: 500, h: 200 },
|
|
67
|
+
mechanism: 'browser',
|
|
68
|
+
source: 'javascript_dialog'
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Accept the pending dialog.
|
|
74
|
+
* @param {string} [value] - Optional value for prompt dialogs
|
|
75
|
+
*/
|
|
76
|
+
export async function acceptDialog(value) {
|
|
77
|
+
if (!pendingDialog) {
|
|
78
|
+
throw new Error('No dialog to accept');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const dialog = pendingDialog.dialog;
|
|
82
|
+
pendingDialog = null;
|
|
83
|
+
|
|
84
|
+
// Accept resolves the dialog and unblocks the page
|
|
85
|
+
// Only pass value if it's a non-empty string (for prompt dialogs)
|
|
86
|
+
if (value && typeof value === 'string') {
|
|
87
|
+
await dialog.accept(value);
|
|
88
|
+
} else {
|
|
89
|
+
await dialog.accept();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Dismiss the pending dialog.
|
|
95
|
+
*/
|
|
96
|
+
export async function dismissDialog() {
|
|
97
|
+
if (!pendingDialog) {
|
|
98
|
+
throw new Error('No dialog to dismiss');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const dialog = pendingDialog.dialog;
|
|
102
|
+
pendingDialog = null;
|
|
103
|
+
|
|
104
|
+
// Dismiss resolves the dialog and unblocks the page
|
|
105
|
+
await dialog.dismiss();
|
|
106
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
class ResultsUploader
|
|
8
|
+
DEFAULT_BASE_URL = 'https://api.specsage.com'
|
|
9
|
+
VERSION = '0.1.0'
|
|
10
|
+
|
|
11
|
+
class UploadError < StandardError; end
|
|
12
|
+
|
|
13
|
+
attr_reader :base_url, :api_key
|
|
14
|
+
|
|
15
|
+
def initialize(base_url: nil, api_key: nil)
|
|
16
|
+
@base_url = base_url || DEFAULT_BASE_URL
|
|
17
|
+
@api_key = api_key || ENV['SPEC_SAGE_API_KEY']
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Fetch all scenario definitions from the server
|
|
21
|
+
# Returns array of scenario definition hashes
|
|
22
|
+
def fetch_scenario_definitions
|
|
23
|
+
response = get('/api/scenario_definitions')
|
|
24
|
+
response['scenario_definitions'] || []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Fetch a single scenario definition by ID
|
|
28
|
+
# Returns scenario definition hash
|
|
29
|
+
def fetch_scenario_definition(id)
|
|
30
|
+
response = get("/api/scenario_definitions/#{id}")
|
|
31
|
+
response['scenario_definition']
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Create a new run on the server
|
|
35
|
+
# Returns server_run_id
|
|
36
|
+
def create_run
|
|
37
|
+
response = post('/api/runs', {
|
|
38
|
+
runner: {
|
|
39
|
+
os: RUBY_PLATFORM.split('-').last,
|
|
40
|
+
arch: RUBY_PLATFORM.split('-').first,
|
|
41
|
+
version: VERSION
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
response['server_run_id']
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Finalize a run on the server
|
|
49
|
+
def finalize_run(server_run_id)
|
|
50
|
+
post("/api/runs/#{server_run_id}/finalize", {})
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Create a CI run - server selects scenarios based on website's "ci" tag
|
|
54
|
+
# Returns hash with server_run_id and base_url
|
|
55
|
+
def create_ci_run(website_identifier)
|
|
56
|
+
post('/api/v1/ci/runs', { website: website_identifier })
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def get(path)
|
|
62
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
63
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
64
|
+
http.use_ssl = uri.scheme == 'https'
|
|
65
|
+
http.open_timeout = 10
|
|
66
|
+
http.read_timeout = 30
|
|
67
|
+
|
|
68
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
69
|
+
request['Accept'] = 'application/json'
|
|
70
|
+
request['Authorization'] = "Bearer #{@api_key}" if @api_key
|
|
71
|
+
|
|
72
|
+
response = http.request(request)
|
|
73
|
+
handle_response(response, path)
|
|
74
|
+
rescue Errno::ECONNREFUSED
|
|
75
|
+
raise UploadError, "Could not connect to server at #{@base_url}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def post(path, body)
|
|
79
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
80
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
81
|
+
http.use_ssl = uri.scheme == 'https'
|
|
82
|
+
http.open_timeout = 10
|
|
83
|
+
http.read_timeout = 30
|
|
84
|
+
|
|
85
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
86
|
+
request['Content-Type'] = 'application/json'
|
|
87
|
+
request['Accept'] = 'application/json'
|
|
88
|
+
request['Authorization'] = "Bearer #{@api_key}" if @api_key
|
|
89
|
+
request.body = body.to_json
|
|
90
|
+
|
|
91
|
+
response = http.request(request)
|
|
92
|
+
handle_response(response, path)
|
|
93
|
+
rescue Errno::ECONNREFUSED
|
|
94
|
+
raise UploadError, "Could not connect to server at #{@base_url}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def handle_response(response, path)
|
|
98
|
+
parsed = parse_response_body(response)
|
|
99
|
+
|
|
100
|
+
case response
|
|
101
|
+
when Net::HTTPSuccess
|
|
102
|
+
parsed
|
|
103
|
+
when Net::HTTPNotFound
|
|
104
|
+
raise UploadError, "Not found: #{parsed['error'] || path}"
|
|
105
|
+
when Net::HTTPConflict
|
|
106
|
+
raise UploadError, "Conflict: #{parsed['error']}"
|
|
107
|
+
when Net::HTTPUnprocessableEntity
|
|
108
|
+
errors = parsed['errors'] || parsed['details'] || [parsed['error']]
|
|
109
|
+
raise UploadError, "Validation failed: #{Array(errors).join(', ')}"
|
|
110
|
+
else
|
|
111
|
+
raise UploadError, "Server error #{response.code}: #{parsed['error'] || response.message}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def parse_response_body(response)
|
|
116
|
+
body = response.body.to_s
|
|
117
|
+
content_type = response['Content-Type'].to_s
|
|
118
|
+
|
|
119
|
+
if body.empty?
|
|
120
|
+
return {}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
unless content_type.include?('application/json')
|
|
124
|
+
snippet = body.length > 200 ? "#{body[0, 200]}..." : body
|
|
125
|
+
raise UploadError, "Expected JSON response but got #{content_type.inspect}: #{snippet}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
JSON.parse(body)
|
|
129
|
+
rescue JSON::ParserError => e
|
|
130
|
+
snippet = body.length > 200 ? "#{body[0, 200]}..." : body
|
|
131
|
+
raise UploadError, "Invalid JSON response from server: #{e.message}. Body: #{snippet}"
|
|
132
|
+
end
|
|
133
|
+
end
|