package jsonrpc import ( "bufio" "context" "encoding/json" "errors" "fmt" "os" "strings" "sync" "testing" "time" ) // TestMain lets this test binary double as a mock JSON-RPC server subprocess. // When JSONRPC_TEST_SERVER is set the process runs the echo server and exits; // otherwise it runs the normal test suite. This is the standard Go pattern for // exercising a subprocess transport without shipping a separate helper binary. func TestMain(m *testing.M) { switch os.Getenv("JSONRPC_TEST_SERVER") { case "echo": runEchoServer() return case "silent": // Read and discard everything, never reply — used for timeout tests. sc := bufio.NewScanner(os.Stdin) sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) for sc.Scan() { } return } os.Exit(m.Run()) } // runEchoServer replies to each request: method "echo" returns its params, // method "fail" returns a JSON-RPC error, notifications produce no reply. func runEchoServer() { scanner := bufio.NewScanner(os.Stdin) scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) w := bufio.NewWriter(os.Stdout) for scanner.Scan() { var req Request if err := json.Unmarshal(scanner.Bytes(), &req); err != nil { continue } if req.ID == nil { continue // notification: no response } var resp Response resp.JSONRPC = Version resp.ID = req.ID switch req.Method { case "fail": resp.Error = &Error{Code: -32000, Message: "boom"} default: resp.Result = req.Params if resp.Result == nil { resp.Result = json.RawMessage(`null`) } } out, _ := json.Marshal(&resp) out = append(out, '\n') _, _ = w.Write(out) _ = w.Flush() } } // newTestClient starts this test binary as a mock server in the given mode. func newTestClient(t *testing.T, mode string) *Client { t.Helper() exe, err := os.Executable() if err != nil { t.Fatalf("os.Executable: %v", err) } c, err := NewClient(Config{ Command: exe, Env: append(os.Environ(), "JSONRPC_TEST_SERVER="+mode), }) if err != nil { t.Fatalf("NewClient: %v", err) } t.Cleanup(func() { _ = c.Close() }) return c } func TestCallEcho(t *testing.T) { c := newTestClient(t, "echo") ctx := context.Background() raw, err := c.Call(ctx, "echo", map[string]any{"hello": "world"}) if err != nil { t.Fatalf("Call: %v", err) } var got map[string]string if err := json.Unmarshal(raw, &got); err != nil { t.Fatalf("unmarshal result: %v", err) } if got["hello"] != "world" { t.Fatalf("got %v, want hello=world", got) } } func TestCallServerError(t *testing.T) { c := newTestClient(t, "echo") _, err := c.Call(context.Background(), "fail", nil) if err == nil { t.Fatal("expected error, got nil") } var rpcErr *Error if !errors.As(err, &rpcErr) { t.Fatalf("expected *jsonrpc.Error, got %T: %v", err, err) } if rpcErr.Code != -32000 || !strings.Contains(rpcErr.Message, "boom") { t.Fatalf("unexpected error: %+v", rpcErr) } } // TestConcurrentCalls verifies responses correlate to the right caller when many // requests are in flight at once (id-based correlation). func TestConcurrentCalls(t *testing.T) { c := newTestClient(t, "echo") ctx := context.Background() const n = 50 var wg sync.WaitGroup errs := make([]error, n) for i := range n { wg.Add(1) go func(i int) { defer wg.Done() raw, err := c.Call(ctx, "echo", map[string]int{"n": i}) if err != nil { errs[i] = err return } var got map[string]int if err := json.Unmarshal(raw, &got); err != nil { errs[i] = err return } if got["n"] != i { errs[i] = fmt.Errorf("call %d got n=%d", i, got["n"]) } }(i) } wg.Wait() for i, err := range errs { if err != nil { t.Fatalf("call %d: %v", i, err) } } } // TestCallContextTimeout verifies Call returns when the context is cancelled and // the server never replies. func TestCallContextTimeout(t *testing.T) { c := newTestClient(t, "silent") ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() start := time.Now() _, err := c.Call(ctx, "echo", nil) if err == nil { t.Fatal("expected timeout error, got nil") } if time.Since(start) > 2*time.Second { t.Fatalf("Call blocked too long: %v", time.Since(start)) } } func TestNotifyNoResponse(t *testing.T) { c := newTestClient(t, "echo") if err := c.Notify("ping", map[string]string{"k": "v"}); err != nil { t.Fatalf("Notify: %v", err) } // A subsequent Call must still work (notification produced no stray reply). if _, err := c.Call(context.Background(), "echo", nil); err != nil { t.Fatalf("Call after Notify: %v", err) } } func TestCallAfterClose(t *testing.T) { c := newTestClient(t, "echo") if err := c.Close(); err != nil { t.Fatalf("Close: %v", err) } if _, err := c.Call(context.Background(), "echo", nil); err == nil { t.Fatal("expected error calling closed client") } } func TestNewClientEmptyCommand(t *testing.T) { if _, err := NewClient(Config{}); err == nil { t.Fatal("expected error for empty command") } } func TestIDRoundTrip(t *testing.T) { for _, tc := range []struct { name string raw string }{ {"number", `123`}, {"string", `"abc"`}, } { var id ID if err := json.Unmarshal([]byte(tc.raw), &id); err != nil { t.Fatalf("%s: unmarshal: %v", tc.name, err) } out, err := json.Marshal(id) if err != nil { t.Fatalf("%s: marshal: %v", tc.name, err) } if string(out) != tc.raw { t.Fatalf("%s: round-trip got %s want %s", tc.name, out, tc.raw) } } var bad ID if err := json.Unmarshal([]byte(`true`), &bad); err == nil { t.Fatal("expected error for boolean id") } }