@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.
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "base64"
7
+
8
+ class StepClient
9
+ class StepError < StandardError; end
10
+
11
+ attr_reader :server_run_id
12
+
13
+ def initialize(base_url:, server_run_id:, api_key: nil)
14
+ @base_url = base_url
15
+ @server_run_id = server_run_id
16
+ @api_key = api_key
17
+ end
18
+
19
+ # Upload video for a scenario
20
+ # video_data: binary video data
21
+ # scenario_id: the scenario this video belongs to
22
+ def upload_video(scenario_id:, video_data:)
23
+ return unless video_data
24
+
25
+ # Single request: send base64-encoded video, server stores file and creates record
26
+ post("/api/runs/#{@server_run_id}/video", {
27
+ scenario_id: scenario_id,
28
+ video_base64: Base64.strict_encode64(video_data)
29
+ })
30
+ end
31
+
32
+ # Set a verdict directly on the server (for client-side errors like step limit exceeded)
33
+ # status: "ERROR", "PASS", or "FAIL"
34
+ # reason: explanation string
35
+ def set_verdict(scenario_id:, status:, reason:)
36
+ post("/api/runs/#{@server_run_id}/step", {
37
+ scenario_id: scenario_id,
38
+ client_verdict: { status: status, reason: reason }
39
+ })
40
+ end
41
+
42
+ # Submit a step to the server
43
+ # Returns: { action: Hash, step_number: Integer, continue: Boolean }
44
+ def submit_step(scenario_id:, screenshot_base64:, elements:, previous_action: nil, action_result: nil)
45
+ body = {
46
+ scenario_id: scenario_id,
47
+ screenshot_base64: screenshot_base64,
48
+ elements: elements
49
+ }
50
+
51
+ body[:previous_action] = previous_action if previous_action
52
+ body[:action_result] = action_result if action_result
53
+
54
+ response = post("/api/runs/#{@server_run_id}/step", body)
55
+
56
+ {
57
+ action: response["action"],
58
+ step_number: response["step_number"],
59
+ max_steps: response["max_steps"],
60
+ continue: response["continue"],
61
+ credentials: response["credentials"] || {}
62
+ }
63
+ end
64
+
65
+ private
66
+
67
+ def post(path, body)
68
+ uri = URI.parse("#{@base_url}#{path}")
69
+ http = Net::HTTP.new(uri.host, uri.port)
70
+ http.use_ssl = uri.scheme == "https"
71
+ http.open_timeout = 10
72
+ http.read_timeout = 120 # LLM calls can take a while
73
+
74
+ request = Net::HTTP::Post.new(uri.request_uri)
75
+ request["Content-Type"] = "application/json"
76
+ request["Accept"] = "application/json"
77
+ request["Authorization"] = "Bearer #{@api_key}" if @api_key
78
+ request.body = body.to_json
79
+
80
+ response = http.request(request)
81
+ handle_response(response, path)
82
+ rescue Errno::ECONNREFUSED
83
+ raise StepError, "Could not connect to server at #{@base_url}"
84
+ rescue Net::ReadTimeout
85
+ raise StepError, "Server timeout - LLM may be taking too long"
86
+ end
87
+
88
+ def handle_response(response, path)
89
+ parsed = parse_response_body(response)
90
+
91
+ case response
92
+ when Net::HTTPSuccess
93
+ parsed
94
+ when Net::HTTPNotFound
95
+ raise StepError, "Not found: #{parsed['error'] || path}"
96
+ when Net::HTTPServiceUnavailable
97
+ raise StepError, "Service unavailable: #{parsed['error']}"
98
+ when Net::HTTPUnprocessableEntity
99
+ raise StepError, "Validation error: #{parsed['error']}"
100
+ else
101
+ raise StepError, "Server error #{response.code}: #{parsed['error'] || response.message}"
102
+ end
103
+ end
104
+
105
+ def parse_response_body(response)
106
+ body = response.body.to_s
107
+ content_type = response["Content-Type"].to_s
108
+
109
+ return {} if body.empty?
110
+
111
+ unless content_type.include?("application/json")
112
+ snippet = body.length > 200 ? "#{body[0, 200]}..." : body
113
+ raise StepError, "Expected JSON response but got #{content_type.inspect}: #{snippet}"
114
+ end
115
+
116
+ JSON.parse(body)
117
+ rescue JSON::ParserError => e
118
+ snippet = body.length > 200 ? "#{body[0, 200]}..." : body
119
+ raise StepError, "Invalid JSON response from server: #{e.message}. Body: #{snippet}"
120
+ end
121
+ end
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@specsage/cli",
3
+ "version": "0.1.0",
4
+ "description": "SpecSage CLI - AI-powered end-to-end testing automation (Node wrapper for Ruby CLI)",
5
+ "type": "module",
6
+ "bin": {
7
+ "specsage": "./bin/specsage.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "lib"
12
+ ],
13
+ "scripts": {
14
+ "test": "node --test"
15
+ },
16
+ "keywords": [
17
+ "testing",
18
+ "e2e",
19
+ "automation",
20
+ "playwright",
21
+ "ai"
22
+ ],
23
+ "author": "SpecSage",
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/specsage/specsage.git",
28
+ "directory": "packages/cli"
29
+ },
30
+ "engines": {
31
+ "node": ">=18.0.0"
32
+ }
33
+ }