Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ One harness works with:
| **`nexssp/kernel` actions** | Auto-mount actions, context bridge for tenant/user, route discovery |
| **Standard `http.Handler`** | Works with Chi, Gin, Echo, stdlib `http.ServeMux` — zero kernel required |
| **Live E2E URLs** | Black-box staging/prod tests using the exact same fluent DSL |
| **JSON-RPC / Stdio transports** | Test line-delimited protocols (MCP stdio, custom JSON-RPC) with a real client |

### Why testkit outperforms ordinary testing setups

Expand All @@ -26,6 +27,7 @@ One harness works with:
- 📡 **SSE capture** — test realtime event streams natively
- 💣 **Chaos injection** — latency, 503s, and panics with a few lines
- 🧩 **Deterministic retry scripting** — unit-test circuit breakers without fakes
- 🔌 **JSON-RPC / Stdio testing** — test non-HTTP transports with the same real-client style

### The developer experience

Expand Down Expand Up @@ -84,6 +86,7 @@ go get github.com/nexssp/testkit@latest
9. [Concurrency & Thundering-Herd Barriers](#9-concurrency--thundering-herd-barriers)
10. [In-Process Load Testing & P99 Latency Profiling](#10-in-process-load-testing--p99-latency-profiling)
11. [Chaos & Fault Injection](#11-chaos--fault-injection)
12. [Testing JSON-RPC / Stdio Transports](#12-testing-json-rpc--stdio-transports)

---

Expand Down Expand Up @@ -317,6 +320,31 @@ func TestRealtimeTelemetry_SSE(t *testing.T) {
}
```

For MCP-style SSE handshakes, `Endpoint()` extracts the `event: endpoint` data, and `WaitForData()` blocks until an event contains a substring:

```go
stream := suite.ListenSSE("/mcp/sse")
endpoint := stream.Endpoint(t, 2*time.Second) // "/mcp/message?sessionId=..."

// POST to endpoint...

msg := stream.WaitForData(t, "result", 2*time.Second)
```

For A2A / MCP Streamable HTTP (POST + SSE), use `ListenSSEWithRequest`:

```go
req := httptest.NewRequest(http.MethodPost, "/endpoint",
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"message/send"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")

stream := suite.ListenSSEWithRequest(t, req)
defer stream.Close()

evt := stream.WaitFor(t, "message", 2*time.Second)
```

---

## 8. Deterministic Scripting & Hook Event Recording
Expand Down Expand Up @@ -492,6 +520,65 @@ func TestResilience_UnderNetworkChaos(t *testing.T) {

---

## 12. Testing JSON-RPC / Stdio Transports

Use `testkit/rpc` for line-delimited JSON-RPC transports such as MCP stdio. It dials an in-memory `net.Pipe` and speaks JSON-RPC 2.0 exactly like a real client.

```go
import (
"context"
"io"
"testing"

"github.com/nexssp/testkit/rpc"
)

func TestMCP_Stdio(t *testing.T) {
client := rpc.DialJSONRPC(t, func(ctx context.Context, in io.Reader, out io.Writer) error {
return mcpServer.Serve(ctx, in, out)
})

resp := client.Call("tools/list", nil, 1)

var data struct {
Tools []struct {
Name string `json:"name"`
} `json:"tools"`
}
resp.BindResult(t, &data)

// Assert with standard Go testing
if len(data.Tools) != 1 {
t.Fatalf("expected 1 tool, got %d", len(data.Tools))
}
}
```

### `rpc.Client` API

```go
client := rpc.DialJSONRPC(t, serveFunc)

// Request / response
resp := client.Call("tools/list", nil, 1)

// Notification (no response)
_ = client.Notify("notifications/initialized", nil)

// Raw payload line (e.g. parse-error tests)
resp = client.CallRaw(`{"jsonrpc":"2.0","id":1,"method":"ping"}`)

// Typed result binding
resp.BindResult(t, &myStruct)

// JSON-RPC error object
if resp.Error != nil {
t.Fatalf("RPC error: %+v", resp.Error)
}
```

---

## License

Apache License 2.0. See [LICENSE](LICENSE) for details.
181 changes: 181 additions & 0 deletions rpc/jsonrpc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package rpc

import (
"context"
"encoding/json"
"errors"
"io"
"net"
"testing"
)

// Request is a JSON-RPC 2.0 request object.
type Request struct {
JSONRPC string `json:"jsonrpc"`
ID any `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}

// Response is a JSON-RPC 2.0 response object.
type Response struct {
JSONRPC string `json:"jsonrpc"`
ID any `json:"id,omitempty"`
Result any `json:"result,omitempty"`
Error *RPCError `json:"error,omitempty"`
}

// RPCError represents a JSON-RPC error object.
type RPCError struct {
Code int `json:"code"`
Message string `json:"message"`
}

func (e *RPCError) Error() string { return e.Message }

// Client is a JSON-RPC 2.0 test client connected to an in-memory pipe.
type Client struct {
t testing.TB
conn net.Conn
enc *json.Encoder
dec *json.Decoder
cancel context.CancelFunc
done chan struct{}
}

// DialJSONRPC creates a client and runs serve in a goroutine over net.Pipe.
func DialJSONRPC(t testing.TB, serve func(ctx context.Context, in io.Reader, out io.Writer) error) *Client {
t.Helper()

serverConn, clientConn := net.Pipe()
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})

go func() {
defer close(done)
defer serverConn.Close()

err := serve(ctx, serverConn, serverConn)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, context.Canceled) {
t.Errorf("rpc: server error: %v", err)
}
}()

c := &Client{
t: t,
conn: clientConn,
enc: json.NewEncoder(clientConn),
dec: json.NewDecoder(clientConn),
cancel: cancel,
done: done,
}

t.Cleanup(func() {
cancel()
_ = clientConn.Close()
<-done
})

return c
}

// Call sends a JSON-RPC request and waits for the matching response.
func (c *Client) Call(method string, params any, id any) Response {
c.t.Helper()

raw, err := marshalParams(params)
if err != nil {
c.t.Fatalf("rpc: marshal params failed: %v", err)
}

req := Request{
JSONRPC: "2.0",
ID: id,
Method: method,
Params: raw,
}

if err := c.enc.Encode(req); err != nil {
c.t.Fatalf("rpc: write request failed: %v", err)
}

var resp Response
if err := c.dec.Decode(&resp); err != nil {
c.t.Fatalf("rpc: read response failed: %v", err)
}

return resp
}

// CallRaw sends a raw payload line and reads a response.
func (c *Client) CallRaw(payload string) Response {
c.t.Helper()

if _, err := io.WriteString(c.conn, payload+"\n"); err != nil {
c.t.Fatalf("rpc: write raw request failed: %v", err)
}

var resp Response
if err := c.dec.Decode(&resp); err != nil {
c.t.Fatalf("rpc: read response failed: %v", err)
}

return resp
}

// Notify sends a JSON-RPC notification (no ID, no response).
func (c *Client) Notify(method string, params any) error {
raw, err := marshalParams(params)
if err != nil {
return err
}

req := Request{
JSONRPC: "2.0",
Method: method,
Params: raw,
}

return c.enc.Encode(req)
}

// Close closes the client connection.
func (c *Client) Close() error {
c.cancel()
err := c.conn.Close()
<-c.done
return err
}

// BindResult unmarshals a successful result into v.
func (r *Response) BindResult(t testing.TB, v any) {
t.Helper()

if r.Error != nil {
t.Fatalf("rpc: response contains error: %+v", r.Error)
}
if r.Result == nil {
t.Fatalf("rpc: response result is nil")
}

data, err := json.Marshal(r.Result)
if err != nil {
t.Fatalf("rpc: marshal result failed: %v", err)
}
if err := json.Unmarshal(data, v); err != nil {
t.Fatalf("rpc: unmarshal result into %T failed: %v", v, err)
}
}

func marshalParams(params any) (json.RawMessage, error) {
if params == nil {
return nil, nil
}

data, err := json.Marshal(params)
if err != nil {
return nil, err
}

return json.RawMessage(data), nil
}
Loading
Loading