feat: add go helper client and mcp tools
This commit is contained in:
266
internal/helperclient/client_test.go
Normal file
266
internal/helperclient/client_test.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package helperclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestContractVersionRequestMarshalsProtocol(t *testing.T) {
|
||||
req := HelperRequest{
|
||||
Protocol: "isphere.helper.v1",
|
||||
RequestID: "req-version-1",
|
||||
Op: "version",
|
||||
TimeoutMS: 5000,
|
||||
Args: map[string]any{},
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(encoded, &got); err != nil {
|
||||
t.Fatalf("unmarshal request json: %v", err)
|
||||
}
|
||||
|
||||
if got["protocol"] != "isphere.helper.v1" {
|
||||
t.Fatalf("protocol = %v, want isphere.helper.v1; json=%s", got["protocol"], encoded)
|
||||
}
|
||||
if got["request_id"] != "req-version-1" {
|
||||
t.Fatalf("request_id = %v, want req-version-1", got["request_id"])
|
||||
}
|
||||
if got["op"] != "version" {
|
||||
t.Fatalf("op = %v, want version", got["op"])
|
||||
}
|
||||
if _, ok := got["args"].(map[string]any); !ok {
|
||||
t.Fatalf("args missing or not an object: %#v", got["args"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractSuccessResponsePreservesDataFields(t *testing.T) {
|
||||
payload := []byte(`{
|
||||
"protocol":"isphere.helper.v1",
|
||||
"request_id":"req-version-1",
|
||||
"op":"version",
|
||||
"ok":true,
|
||||
"data":{
|
||||
"helper_name":"ISphereWinHelper",
|
||||
"extra_unknown":{"nested":true}
|
||||
},
|
||||
"error":null,
|
||||
"warnings":[]
|
||||
}`)
|
||||
|
||||
var resp HelperResponse
|
||||
if err := json.Unmarshal(payload, &resp); err != nil {
|
||||
t.Fatalf("unmarshal success response: %v", err)
|
||||
}
|
||||
|
||||
if !resp.OK {
|
||||
t.Fatalf("ok = false, want true")
|
||||
}
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("error = %#v, want nil", resp.Error)
|
||||
}
|
||||
|
||||
var helperName string
|
||||
if err := json.Unmarshal(resp.Data["helper_name"], &helperName); err != nil {
|
||||
t.Fatalf("unmarshal helper_name from data: %v", err)
|
||||
}
|
||||
if helperName != "ISphereWinHelper" {
|
||||
t.Fatalf("data.helper_name = %q, want ISphereWinHelper", helperName)
|
||||
}
|
||||
|
||||
if _, ok := resp.Data["extra_unknown"]; !ok {
|
||||
t.Fatalf("unknown data field extra_unknown was not preserved: %#v", resp.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractErrorResponseUnmarshalsErrorCode(t *testing.T) {
|
||||
payload := []byte(`{
|
||||
"protocol":"isphere.helper.v1",
|
||||
"request_id":"req-scan-1",
|
||||
"op":"scan_windows",
|
||||
"ok":false,
|
||||
"data":null,
|
||||
"error":{
|
||||
"code":"WINDOW_NOT_FOUND",
|
||||
"message":"no matching iSphere window found"
|
||||
},
|
||||
"warnings":[]
|
||||
}`)
|
||||
|
||||
var resp HelperResponse
|
||||
if err := json.Unmarshal(payload, &resp); err != nil {
|
||||
t.Fatalf("unmarshal error response: %v", err)
|
||||
}
|
||||
|
||||
if resp.OK {
|
||||
t.Fatalf("ok = true, want false")
|
||||
}
|
||||
if resp.Error == nil {
|
||||
t.Fatalf("error = nil, want HelperError")
|
||||
}
|
||||
if resp.Error.Code != "WINDOW_NOT_FOUND" {
|
||||
t.Fatalf("error.code = %q, want WINDOW_NOT_FOUND", resp.Error.Code)
|
||||
}
|
||||
if len(resp.Data) != 0 {
|
||||
t.Fatalf("data = %#v, want nil or empty for JSON null", resp.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientMissingHelperPathReturnsStructuredError(t *testing.T) {
|
||||
client := Client{HelperPath: filepath.Join(t.TempDir(), "missing-helper.exe")}
|
||||
|
||||
resp, err := client.Call(context.Background(), "version", map[string]any{})
|
||||
if resp != nil {
|
||||
t.Fatalf("response = %#v, want nil", resp)
|
||||
}
|
||||
|
||||
clientErr := requireClientError(t, err)
|
||||
if clientErr.Code != ErrorCodeMissingHelper {
|
||||
t.Fatalf("error code = %q, want %q", clientErr.Code, ErrorCodeMissingHelper)
|
||||
}
|
||||
if clientErr.HelperPath != client.HelperPath {
|
||||
t.Fatalf("helper path = %q, want %q", clientErr.HelperPath, client.HelperPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientBadHelperOutputReturnsStructuredError(t *testing.T) {
|
||||
stub := buildHelperStub(t, `package main
|
||||
import "fmt"
|
||||
func main() { fmt.Println("not-json") }
|
||||
`)
|
||||
client := Client{HelperPath: stub}
|
||||
|
||||
resp, err := client.Call(context.Background(), "version", map[string]any{})
|
||||
if resp != nil {
|
||||
t.Fatalf("response = %#v, want nil", resp)
|
||||
}
|
||||
|
||||
clientErr := requireClientError(t, err)
|
||||
if clientErr.Code != ErrorCodeBadJSON {
|
||||
t.Fatalf("error code = %q, want %q", clientErr.Code, ErrorCodeBadJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientTimeoutReturnsStructuredError(t *testing.T) {
|
||||
stub := buildHelperStub(t, `package main
|
||||
import "time"
|
||||
func main() { time.Sleep(10 * time.Second) }
|
||||
`)
|
||||
client := Client{HelperPath: stub}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
started := time.Now()
|
||||
resp, err := client.Call(ctx, "version", map[string]any{})
|
||||
elapsed := time.Since(started)
|
||||
if resp != nil {
|
||||
t.Fatalf("response = %#v, want nil", resp)
|
||||
}
|
||||
if elapsed > 3*time.Second {
|
||||
t.Fatalf("timeout call took %s, want process terminated promptly", elapsed)
|
||||
}
|
||||
|
||||
clientErr := requireClientError(t, err)
|
||||
if clientErr.Code != ErrorCodeTimeout {
|
||||
t.Fatalf("error code = %q, want %q", clientErr.Code, ErrorCodeTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientNonZeroExitCapturesStderrAsStructuredError(t *testing.T) {
|
||||
stub := buildHelperStub(t, `package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
func main() {
|
||||
fmt.Fprintln(os.Stderr, "stub stderr boom")
|
||||
os.Exit(7)
|
||||
}
|
||||
`)
|
||||
client := Client{HelperPath: stub}
|
||||
|
||||
resp, err := client.Call(context.Background(), "version", map[string]any{})
|
||||
if resp != nil {
|
||||
t.Fatalf("response = %#v, want nil", resp)
|
||||
}
|
||||
|
||||
clientErr := requireClientError(t, err)
|
||||
if clientErr.Code != ErrorCodeProcessFailed {
|
||||
t.Fatalf("error code = %q, want %q", clientErr.Code, ErrorCodeProcessFailed)
|
||||
}
|
||||
if clientErr.ExitCode != 7 {
|
||||
t.Fatalf("exit code = %d, want 7", clientErr.ExitCode)
|
||||
}
|
||||
if !strings.Contains(clientErr.Stderr, "stub stderr boom") {
|
||||
t.Fatalf("stderr = %q, want stub stderr boom", clientErr.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRealVersionCallWhenHelperIsBuilt(t *testing.T) {
|
||||
helperPath := defaultHelperPath()
|
||||
if _, err := os.Stat(helperPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
t.Skipf("real helper is not built at %s", helperPath)
|
||||
}
|
||||
t.Fatalf("stat real helper: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
resp, err := (Client{}).Call(ctx, "version", map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("real version call returned error: %v", err)
|
||||
}
|
||||
if resp == nil || !resp.OK {
|
||||
t.Fatalf("real version response = %#v, want ok=true", resp)
|
||||
}
|
||||
|
||||
var helperName string
|
||||
if err := json.Unmarshal(resp.Data["helper_name"], &helperName); err != nil {
|
||||
t.Fatalf("unmarshal real helper_name: %v", err)
|
||||
}
|
||||
if helperName != "ISphereWinHelper" {
|
||||
t.Fatalf("real data.helper_name = %q, want ISphereWinHelper", helperName)
|
||||
}
|
||||
t.Logf("real helper version ok: helper_name=%s path=%s", helperName, helperPath)
|
||||
}
|
||||
|
||||
func requireClientError(t *testing.T, err error) *ClientError {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("error = nil, want *ClientError")
|
||||
}
|
||||
var clientErr *ClientError
|
||||
if !errors.As(err, &clientErr) {
|
||||
t.Fatalf("error type = %T, want *ClientError: %v", err, err)
|
||||
}
|
||||
return clientErr
|
||||
}
|
||||
|
||||
func buildHelperStub(t *testing.T, source string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
sourcePath := filepath.Join(dir, "main.go")
|
||||
if err := os.WriteFile(sourcePath, []byte(source), 0600); err != nil {
|
||||
t.Fatalf("write stub source: %v", err)
|
||||
}
|
||||
|
||||
exePath := filepath.Join(dir, "helper-stub.exe")
|
||||
cmd := exec.Command("go", "build", "-o", exePath, sourcePath)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("build stub helper: %v\n%s", err, output)
|
||||
}
|
||||
return exePath
|
||||
}
|
||||
Reference in New Issue
Block a user