ollie/toolsrv/rpc_integration_test.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")
}