Files
HomeAgent/internal/plugins/mcp/test/server_contract_test.go
JianFeeeee 3d05dcac71 test: 按模块导出接口重写黑盒判据(批次 14:mcp / proc / cfgmgr)
三个模块新增 test/ 黑盒契约,测试仅通过导出 API 驱动。

## internal/plugins/mcp

    TestListToolsThroughHTTP
    TestCallToolSuccessAndEmptyContentHandling
    TestCallToolSurfacesIsErrorAsFailure
    TestCallToolSurfacesRPCError
    TestCallToolSurfacesHTTPError
    TestListToolsParseSSEStreamFrame

使用 httptest + NewSSETransport + NewServer,覆盖 JSON-RPC 工具列表、
成功调用、空内容失败、MCP isError、RPC 错误、HTTP 错误和 SSE data 帧。

## internal/plugin/proc

    TestMajorVersionParsing
    TestKnownCapabilitiesExcludesCoreAndIsStable
    TestSharedRefSliceBoundary
    TestPublicCapabilityConstantsStable

覆盖版本主段解析、能力清单排序/去重/core 排除、共享引用切片边界和能力常量。

## internal/plugins/cfgmgr

    TestStartRegistersConfigToolsWithConcurrencyMarkers
    TestStopIsSafe

通过公开 Start 注入 SDK 工具注册回调,观察七个配置工具及其
Serial/ParallelSafe 并发契约;未访问私有 handler。

## 变异验证

    mcp       忽略 MCP isError         → isError 未被判失败
    proc      MajorVersion 不截断       → 多组版本断言红
    cfgmgr    写工具取消 Serial 标记    → 写工具未声明 Serial

## 验证

    go build ./...                            PASS
    go vet ./...                              PASS
    gofmt -l                                  干净
    python3 .github/scripts/check-ci-gates.py PASS
    go test ./... -count=1                    51 个测试包 / 100 包无测试 / 0 FAIL
    3 个新包 -race                            全绿
    源码目录 _test.go                         0
2026-11-09 21:37:53 +08:00

121 lines
4.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package test
// mcp 的黑盒判据:httptest 提供真实 HTTP JSON-RPC 输入,
// 通过公开 NewSSETransport/NewServer/ListTools/CallTool 观察输出。
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/plugins/mcp"
)
func rpcServer(t *testing.T, handler func(method string, body []byte) (status int, ct string, resp string)) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req struct{ Method string }
_ = json.Unmarshal(body, &req)
st, ct, resp := handler(req.Method, body)
w.Header().Set("Content-Type", ct)
w.WriteHeader(st)
_, _ = w.Write([]byte(resp))
}))
t.Cleanup(srv.Close)
return srv
}
func newClient(t *testing.T, url string) *mcp.Server {
t.Helper()
s := mcp.NewServer("contract-mcp", mcp.NewSSETransport(url))
t.Cleanup(func() { _ = s.Close() })
return s
}
func TestListToolsThroughHTTP(t *testing.T) {
srv := rpcServer(t, func(method string, body []byte) (int, string, string) {
if method != "tools/list" {
t.Errorf("method = %q,期望 tools/list", method)
}
return 200, "application/json", `{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"read_file","description":"读取文件"},{"name":"write_file","description":"写文件"}]}}`
})
tools, err := newClient(t, srv.URL).ListTools()
if err != nil {
t.Fatalf("ListTools: %v", err)
}
if len(tools) != 2 || tools[0].Name != "read_file" || tools[1].Name != "write_file" {
t.Fatalf("tools = %+v", tools)
}
}
func TestCallToolSuccessAndEmptyContentHandling(t *testing.T) {
srv := rpcServer(t, func(method string, body []byte) (int, string, string) {
if method != "tools/call" {
t.Errorf("method = %q,期望 tools/call", method)
}
return 200, "application/json", `{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"world"}],"isError":false}}`
})
out, err := newClient(t, srv.URL).CallTool("greet", map[string]interface{}{"x": 1})
if err != nil {
t.Fatalf("CallTool: %v", err)
}
if out != "world" {
t.Fatalf("out = %q", out)
}
emptySrv := rpcServer(t, func(method string, body []byte) (int, string, string) {
return 200, "application/json", `{"jsonrpc":"2.0","id":1,"result":{"content":[]}}`
})
if _, err := newClient(t, emptySrv.URL).CallTool("x", nil); err == nil || !strings.Contains(err.Error(), "未返回任何文本") {
t.Fatalf("空内容未被判失败:%v", err)
}
}
func TestCallToolSurfacesIsErrorAsFailure(t *testing.T) {
srv := rpcServer(t, func(method string, body []byte) (int, string, string) {
return 200, "application/json", `{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"id is required"}],"isError":true}}`
})
_, err := newClient(t, srv.URL).CallTool("x", nil)
if err == nil || !strings.Contains(err.Error(), "id is required") {
t.Fatalf("isError 未被判失败:%v", err)
}
}
func TestCallToolSurfacesRPCError(t *testing.T) {
srv := rpcServer(t, func(method string, body []byte) (int, string, string) {
return 200, "application/json", `{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"bad params"}}`
})
_, err := newClient(t, srv.URL).CallTool("x", nil)
if err == nil || !strings.Contains(err.Error(), "bad params") {
t.Fatalf("RPC error 未被透出:%v", err)
}
}
func TestCallToolSurfacesHTTPError(t *testing.T) {
srv := rpcServer(t, func(method string, body []byte) (int, string, string) {
return 500, "text/plain", "boom"
})
_, err := newClient(t, srv.URL).CallTool("x", nil)
if err == nil || !strings.Contains(err.Error(), "http status 500") {
t.Fatalf("HTTP 错误未被透出:%v", err)
}
}
func TestListToolsParseSSEStreamFrame(t *testing.T) {
srv := rpcServer(t, func(method string, body []byte) (int, string, string) {
frame := `{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"sse_tool","description":"d"}]}}`
return 200, "text/event-stream", "event: message\ndata: " + frame + "\n\n"
})
tools, err := newClient(t, srv.URL).ListTools()
if err != nil {
t.Fatalf("SSE LL: %v", err)
}
if len(tools) != 1 || tools[0].Name != "sse_tool" {
t.Fatalf("tools=%+v", tools)
}
}