Files
agents_and_robots/cmd/devicemesh-mcp/integration_test.go
T
egutierrez d1fd78324b 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>
2026-05-24 18:33:24 +02:00

178 lines
4.4 KiB
Go

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
}