test(0145): unit + integration + launcher + claudecode coverage
cmd/devicemesh-mcp/main_test.go (10 tests): - TestInitialize: JSON-RPC initialize frame → serverInfo + capabilities. - TestToolsList: tools/list → 16 user-mode entries, cada uno con name + inputSchema valido. - TestToolsCallExec: tools/call name=exec → mock device-agent (httptest) recibe capability=shell.exec, MCP response content contiene "hi". - TestToolsCallInvalidTool: name desconocido → isError o error envelope. - TestNotificationsInitializedNoResponse: notification (sin id) → cero responses. - TestUserModeFiltersPkgInstall: --mode user oculta pkg.install, --mode sudo la expone. - TestToolsAllowedNarrows: --tools-allowed exec,fs.read → solo 2. - TestSplitCSV, TestParseMode, TestIsCleanShutdown: helpers. cmd/devicemesh-mcp/integration_test.go: - TestIntegrationBinarySubprocess: build el binario en tmp + spawn como child via exec.Command + pipe real + secuencia initialize -> notifications/initialized -> tools/list -> tools/call. Valida el path identico al que usara claude. devagents/mcp_bridge_test.go (9 tests): - Disabled paths (nil DM, ExposeViaMCP=false, provider!=claude-code). - Applied path: /tmp/<agent>-mcp-config.json JSON valido, mode 0600, mcpServers.devicemesh con command apuntando al binario fake. - AllowedTools formato mcp__<server>__<tool>. - DisableTools=true overrideado a false. - URLEnv override gana sobre YAML. - Binary missing → ok=false sin panico. - BuildClaudeAllowedToolNames default server name. - ResolveBridgedToolNames respeta mode + ToolsAllowed. - ShouldExposeViaMCP cubre nil/disabled/default/explicit-true/false. shell/llm/claudecode_test.go: - TestBuildClaudeArgs_DisableTools actualizado: solo emite --tools "" cuando AllowedTools ESTA vacio. La regla nueva (issue 0145) da precedencia a AllowedTools. - Anadido TestBuildClaudeArgs_DisableToolsButAllowedToolsWins. - Anadido TestBuildClaudeArgs_MCPConfigPath. bridge.go fix: cambio NewTool + WithRawInputSchema a NewToolWithRawSchema porque NewTool inicializa ToolInputSchema.Type="object" por default, lo cual entra en conflicto con RawInputSchema en MarshalJSON del SDK. Suite completa pasa con -tags goolm -count=1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestIntegrationBinarySubprocess builds the binary (or uses an existing
|
||||
// bin/devicemesh-mcp) and exercises a full initialize -> tools/list ->
|
||||
// tools/call sequence over a real OS pipe. This validates that the same
|
||||
// code path that claude will invoke (subprocess + stdio) works end-to-end.
|
||||
//
|
||||
// Skipped when the binary cannot be built or located, so the rest of the
|
||||
// unit tests still run cleanly on minimal sandboxes.
|
||||
func TestIntegrationBinarySubprocess(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("integration test skipped in -short mode")
|
||||
}
|
||||
|
||||
binPath := buildOrLocateBinary(t)
|
||||
if binPath == "" {
|
||||
t.Skip("cannot build/locate devicemesh-mcp binary")
|
||||
}
|
||||
|
||||
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body := map[string]any{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"request_id": body["request_id"],
|
||||
"ok": true,
|
||||
"duration_ms": 7,
|
||||
"result": map[string]any{
|
||||
"stdout": "subprocess hi",
|
||||
"stderr": "",
|
||||
"exit_code": 0,
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer mock.Close()
|
||||
|
||||
cmd := exec.Command(binPath,
|
||||
"--device-agent", mock.URL,
|
||||
"--mode", "user",
|
||||
"--server-name", "devicemesh",
|
||||
)
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("stdin pipe: %v", err)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("stdout pipe: %v", err)
|
||||
}
|
||||
cmd.Stderr = io.Discard
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = stdin.Close()
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
}()
|
||||
|
||||
// Real MCP clients send `notifications/initialized` after the
|
||||
// initialize response is received before sending any other requests.
|
||||
// We mirror the same sequence — without it the server may queue
|
||||
// follow-up frames behind the not-yet-initialized session.
|
||||
frames := []string{
|
||||
initFrame(1),
|
||||
notifInitializedFrame(),
|
||||
toolsListFrame(2),
|
||||
toolsCallFrame(3, "exec", map[string]any{"argv": []any{"echo", "subprocess"}}),
|
||||
}
|
||||
for _, f := range frames {
|
||||
if !strings.HasSuffix(f, "\n") {
|
||||
f += "\n"
|
||||
}
|
||||
if _, err := stdin.Write([]byte(f)); err != nil {
|
||||
t.Fatalf("write frame: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Read responses (up to 3 with timeout).
|
||||
reader := bufio.NewReader(stdout)
|
||||
deadline := time.After(5 * time.Second)
|
||||
responses := make([]map[string]any, 0, 3)
|
||||
|
||||
readCh := make(chan map[string]any, 4)
|
||||
go func() {
|
||||
defer close(readCh)
|
||||
dec := json.NewDecoder(reader)
|
||||
for {
|
||||
var msg map[string]any
|
||||
if err := dec.Decode(&msg); err != nil {
|
||||
return
|
||||
}
|
||||
readCh <- msg
|
||||
}
|
||||
}()
|
||||
|
||||
readLoop:
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-readCh:
|
||||
if !ok {
|
||||
break readLoop
|
||||
}
|
||||
responses = append(responses, msg)
|
||||
if len(responses) >= 3 {
|
||||
break readLoop
|
||||
}
|
||||
case <-deadline:
|
||||
break readLoop
|
||||
}
|
||||
}
|
||||
|
||||
if len(responses) < 3 {
|
||||
t.Fatalf("expected 3 responses, got %d: %v", len(responses), responses)
|
||||
}
|
||||
|
||||
// Validate the tools/call (id=3) response.
|
||||
r := responses[2]
|
||||
if r["id"] != float64(3) {
|
||||
t.Errorf("expected id=3, got %v", r["id"])
|
||||
}
|
||||
result, _ := r["result"].(map[string]any)
|
||||
contents, _ := result["content"].([]any)
|
||||
if len(contents) == 0 {
|
||||
t.Fatalf("missing content in tools/call response: %v", r)
|
||||
}
|
||||
first, _ := contents[0].(map[string]any)
|
||||
text, _ := first["text"].(string)
|
||||
if !strings.Contains(text, "subprocess hi") {
|
||||
t.Errorf("expected text to contain 'subprocess hi', got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
// buildOrLocateBinary returns the absolute path to bin/devicemesh-mcp,
|
||||
// building it under a temp dir if it is missing. Returns "" if neither
|
||||
// option works (the test then skips).
|
||||
func buildOrLocateBinary(t *testing.T) string {
|
||||
t.Helper()
|
||||
// First, try ../../bin/devicemesh-mcp relative to this file (CWD when
|
||||
// `go test ./cmd/devicemesh-mcp/` is the cmd dir itself).
|
||||
candidates := []string{
|
||||
filepath.Join("..", "..", "bin", "devicemesh-mcp"),
|
||||
filepath.Join("bin", "devicemesh-mcp"),
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if abs, err := filepath.Abs(c); err == nil {
|
||||
if st, err := os.Stat(abs); err == nil && !st.IsDir() {
|
||||
return abs
|
||||
}
|
||||
}
|
||||
}
|
||||
// Build into a tmpdir.
|
||||
tmpDir := t.TempDir()
|
||||
out := filepath.Join(tmpDir, "devicemesh-mcp")
|
||||
cmd := exec.Command("/usr/local/go/bin/go", "build", "-tags", "goolm", "-o", out, ".")
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Logf("build failed: %v", err)
|
||||
return ""
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user