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 }