323 lines
7.4 KiB
Go
323 lines
7.4 KiB
Go
package toolsrv_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sync"
|
|
"syscall"
|
|
"testing"
|
|
|
|
"ollie/toolsrv"
|
|
)
|
|
|
|
// --- Test infrastructure ---
|
|
|
|
// rpcRequest mirrors the JSON-RPC 2.0 request format (test-local).
|
|
type rpcRequest struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID int64 `json:"id"`
|
|
Method string `json:"method"`
|
|
Params json.RawMessage `json:"params,omitempty"`
|
|
}
|
|
|
|
// rpcResponse mirrors the JSON-RPC 2.0 response format (test-local).
|
|
type rpcResponse struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID int64 `json:"id"`
|
|
Result json.RawMessage `json:"result,omitempty"`
|
|
Error *rpcError `json:"error,omitempty"`
|
|
Stream bool `json:"stream,omitempty"`
|
|
}
|
|
|
|
type rpcError struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// testServer wraps a toolsrv.Server with a pipe-based RPC connection.
|
|
type testServer struct {
|
|
srv *toolsrv.Server
|
|
conn net.Conn // client side
|
|
enc *json.Encoder
|
|
dec *json.Decoder
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
func newTestServer(t *testing.T) *testServer {
|
|
t.Helper()
|
|
cwd := t.TempDir()
|
|
os.MkdirAll(filepath.Join(cwd, ".git"), 0755)
|
|
|
|
reg, err := toolsrv.NewRegistry()
|
|
if err != nil {
|
|
t.Fatalf("NewRegistry: %v", err)
|
|
}
|
|
|
|
sessionID := "test-session-" + t.Name()
|
|
srv := toolsrv.New(cwd)
|
|
srv.SetToolRegistry(reg, sessionID)
|
|
|
|
clientConn, serverConn := net.Pipe()
|
|
ts := &testServer{
|
|
srv: srv,
|
|
conn: clientConn,
|
|
enc: json.NewEncoder(clientConn),
|
|
dec: json.NewDecoder(clientConn),
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
ts.wg.Add(1)
|
|
go func() {
|
|
defer ts.wg.Done()
|
|
srv.ServeRPC(ctx, serverConn, serverConn)
|
|
}()
|
|
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
clientConn.Close()
|
|
serverConn.Close()
|
|
ts.wg.Wait()
|
|
})
|
|
|
|
return ts
|
|
}
|
|
|
|
// call sends an RPC request and returns the response (skipping stream frames).
|
|
func (ts *testServer) call(method string, params any) rpcResponse {
|
|
var raw json.RawMessage
|
|
if params != nil {
|
|
raw, _ = json.Marshal(params)
|
|
}
|
|
ts.enc.Encode(rpcRequest{JSONRPC: "2.0", ID: 1, Method: method, Params: raw})
|
|
for {
|
|
var resp rpcResponse
|
|
ts.dec.Decode(&resp)
|
|
if !resp.Stream {
|
|
return resp
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Integration tests ---
|
|
|
|
func TestRPC_Ping(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
resp := ts.call("ping", nil)
|
|
|
|
if resp.Error != nil {
|
|
t.Fatalf("unexpected error: %v", resp.Error.Message)
|
|
}
|
|
var result string
|
|
json.Unmarshal(resp.Result, &result)
|
|
if result != "pong" {
|
|
t.Errorf("expected 'pong', got %q", result)
|
|
}
|
|
}
|
|
|
|
func TestRPC_HostInfo(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
resp := ts.call("host_info", nil)
|
|
|
|
if resp.Error != nil {
|
|
t.Fatalf("unexpected error: %v", resp.Error.Message)
|
|
}
|
|
|
|
var info toolsrv.HostInfo
|
|
json.Unmarshal(resp.Result, &info)
|
|
if info.Platform != runtime.GOOS {
|
|
t.Errorf("platform: expected %q, got %q", runtime.GOOS, info.Platform)
|
|
}
|
|
if info.Arch != runtime.GOARCH {
|
|
t.Errorf("arch: expected %q, got %q", runtime.GOARCH, info.Arch)
|
|
}
|
|
if !info.IsGitRepo {
|
|
t.Error("expected is_git_repo=true (we created .git dir)")
|
|
}
|
|
}
|
|
|
|
func TestRPC_ListTools_Empty(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
resp := ts.call("list_tools", nil)
|
|
|
|
if resp.Error != nil {
|
|
t.Fatalf("unexpected error: %v", resp.Error.Message)
|
|
}
|
|
|
|
var tools []toolsrv.ToolInfo
|
|
json.Unmarshal(resp.Result, &tools)
|
|
if len(tools) != 0 {
|
|
t.Errorf("expected 0 tools, got %d", len(tools))
|
|
}
|
|
}
|
|
|
|
func TestRPC_SetEnv(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
|
|
var envKey, envValue string
|
|
ts.srv.OnEnvSet = func(k, v string) {
|
|
envKey, envValue = k, v
|
|
}
|
|
|
|
resp := ts.call("set_env", map[string]string{"key": "TEST_VAR", "value": "test_value"})
|
|
|
|
if resp.Error != nil {
|
|
t.Fatalf("unexpected error: %v", resp.Error.Message)
|
|
}
|
|
if envKey != "TEST_VAR" || envValue != "test_value" {
|
|
t.Errorf("env not set: got key=%q value=%q", envKey, envValue)
|
|
}
|
|
}
|
|
|
|
func TestRPC_SetCWD(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
resp := ts.call("set_cwd", map[string]string{"dir": "/tmp/test-cwd"})
|
|
|
|
if resp.Error != nil {
|
|
t.Fatalf("unexpected error: %v", resp.Error.Message)
|
|
}
|
|
var result bool
|
|
json.Unmarshal(resp.Result, &result)
|
|
if !result {
|
|
t.Error("expected result=true")
|
|
}
|
|
}
|
|
|
|
func TestRPC_Detach_NoRunningProcess(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
resp := ts.call("detach", nil)
|
|
|
|
if resp.Error != nil {
|
|
t.Fatalf("unexpected error: %v", resp.Error.Message)
|
|
}
|
|
var result bool
|
|
json.Unmarshal(resp.Result, &result)
|
|
if result {
|
|
t.Error("expected false when no process running")
|
|
}
|
|
}
|
|
|
|
func TestRPC_ListDetached_Empty(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
resp := ts.call("list_detached", nil)
|
|
|
|
if resp.Error != nil {
|
|
t.Fatalf("unexpected error: %v", resp.Error.Message)
|
|
}
|
|
var list []any
|
|
json.Unmarshal(resp.Result, &list)
|
|
if len(list) != 0 {
|
|
t.Errorf("expected empty list, got %d items", len(list))
|
|
}
|
|
}
|
|
|
|
func TestRPC_SignalDetached_NotFound(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
resp := ts.call("signal_detached", map[string]any{"pid": 99999, "signal": int(syscall.SIGTERM)})
|
|
|
|
if resp.Error == nil {
|
|
t.Fatal("expected error for nonexistent PID")
|
|
}
|
|
if resp.Error.Code != -32000 {
|
|
t.Errorf("expected error code -32000, got %d", resp.Error.Code)
|
|
}
|
|
}
|
|
|
|
func TestRPC_GetDetachedOutput_NotFound(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
resp := ts.call("get_detached_output", map[string]any{"pid": 99999})
|
|
|
|
if resp.Error == nil {
|
|
t.Fatal("expected error for nonexistent PID")
|
|
}
|
|
}
|
|
|
|
func TestRPC_DismissDetached_NotFound(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
resp := ts.call("dismiss_detached", map[string]any{"pid": 99999})
|
|
|
|
if resp.Error != nil {
|
|
t.Fatalf("unexpected error: %v", resp.Error.Message)
|
|
}
|
|
var result bool
|
|
json.Unmarshal(resp.Result, &result)
|
|
if result {
|
|
t.Error("expected false for nonexistent PID")
|
|
}
|
|
}
|
|
|
|
func TestRPC_CallTool_NotLoaded(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
resp := ts.call("nonexistent_tool", map[string]any{"arg": "value"})
|
|
|
|
if resp.Error == nil {
|
|
t.Fatal("expected error for unloaded tool")
|
|
}
|
|
if resp.Error.Code != -32000 {
|
|
t.Errorf("expected error code -32000, got %d", resp.Error.Code)
|
|
}
|
|
}
|
|
|
|
func TestRPC_InvalidParams(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
// Send an array where an object is expected — json.Unmarshal will fail.
|
|
ts.enc.Encode(rpcRequest{JSONRPC: "2.0", ID: 1, Method: "set_env", Params: json.RawMessage(`[1,2,3]`)})
|
|
for {
|
|
var resp rpcResponse
|
|
if err := ts.dec.Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp.Stream {
|
|
continue
|
|
}
|
|
if resp.Error == nil {
|
|
t.Fatal("expected error for invalid params")
|
|
}
|
|
if resp.Error.Code != -32602 {
|
|
t.Errorf("expected error code -32602 (invalid params), got %d", resp.Error.Code)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
// --- Full pipe-based client/server tests ---
|
|
|
|
func TestRPC_PipeConnection(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
os.MkdirAll(filepath.Join(cwd, ".git"), 0755)
|
|
|
|
reg, _ := toolsrv.NewRegistry()
|
|
srv := toolsrv.New(cwd)
|
|
srv.SetToolRegistry(reg, "pipe-test")
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
clientConn, serverConn := net.Pipe()
|
|
defer clientConn.Close()
|
|
defer serverConn.Close()
|
|
|
|
go srv.ServeRPC(ctx, serverConn, serverConn)
|
|
|
|
// Use toolsrv.Conn as the client
|
|
conn := toolsrv.NewConn(clientConn, nil)
|
|
|
|
if err := conn.Ping(); err != nil {
|
|
t.Errorf("Ping failed: %v", err)
|
|
}
|
|
|
|
tools, err := conn.ListTools()
|
|
if err != nil {
|
|
t.Errorf("ListTools failed: %v", err)
|
|
}
|
|
if len(tools) != 0 {
|
|
t.Errorf("expected 0 tools, got %d", len(tools))
|
|
}
|
|
|
|
conn.SetEnv("TEST_KEY", "TEST_VALUE")
|
|
conn.SetCWD("/tmp/test")
|
|
}
|