@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/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