a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
311 lines
9.2 KiB
Go
311 lines
9.2 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/zzet/gortex/internal/daemon"
|
|
)
|
|
|
|
// stubDaemonServer is a minimal in-process daemon that speaks just enough of
|
|
// the AF_UNIX wire protocol for resolveExecutor to treat it as a warm daemon:
|
|
// it ACKs the handshake, answers ControlStatus with a configurable tracked
|
|
// repo set, and echoes a tools/call result back over a ModeMCP channel.
|
|
//
|
|
// It deliberately implements the protocol by hand (rather than booting the
|
|
// real daemon.Server) so the test exercises resolveExecutor end-to-end
|
|
// against an isolated socket without indexing anything.
|
|
type stubDaemonServer struct {
|
|
ln net.Listener
|
|
trackedRepos []string
|
|
|
|
mu sync.Mutex
|
|
lastTool string
|
|
lastArgs map[string]any
|
|
mcpResult json.RawMessage // raw result payload echoed in the content block
|
|
mcpError *stubRPCError // when set, the MCP reply is a JSON-RPC error
|
|
}
|
|
|
|
type stubRPCError struct {
|
|
Code int
|
|
Message string
|
|
ErrorCode string // rides on error.data.error_code
|
|
}
|
|
|
|
// startStubDaemon binds a unix socket, points GORTEX_DAEMON_SOCKET at it,
|
|
// and serves until the test ends. The socket lives under a short temp dir
|
|
// (not t.TempDir()) because the AF_UNIX path limit (~104 bytes on macOS) is
|
|
// shorter than the deeply-nested per-subtest t.TempDir() paths.
|
|
func startStubDaemon(t *testing.T, trackedRepos []string) *stubDaemonServer {
|
|
t.Helper()
|
|
dir, err := os.MkdirTemp("", "gxd")
|
|
if err != nil {
|
|
t.Fatalf("mkdir temp: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
|
sock := filepath.Join(dir, "d.sock")
|
|
ln, err := net.Listen("unix", sock)
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
t.Setenv("GORTEX_DAEMON_SOCKET", sock)
|
|
|
|
s := &stubDaemonServer{
|
|
ln: ln,
|
|
trackedRepos: trackedRepos,
|
|
mcpResult: json.RawMessage(`{"ok":true}`),
|
|
}
|
|
go s.serve()
|
|
t.Cleanup(func() { _ = ln.Close() })
|
|
return s
|
|
}
|
|
|
|
func (s *stubDaemonServer) serve() {
|
|
for {
|
|
conn, err := s.ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go s.handleConn(conn)
|
|
}
|
|
}
|
|
|
|
func (s *stubDaemonServer) handleConn(conn net.Conn) {
|
|
defer conn.Close()
|
|
reader := bufio.NewReader(conn)
|
|
|
|
hsLine, err := reader.ReadBytes('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
var hs daemon.Handshake
|
|
if err := json.Unmarshal(hsLine, &hs); err != nil {
|
|
return
|
|
}
|
|
if err := daemon.WriteJSONLine(conn, daemon.HandshakeAck{OK: true, DaemonVersion: "stub"}); err != nil {
|
|
return
|
|
}
|
|
|
|
switch hs.Mode {
|
|
case daemon.ModeControl:
|
|
s.serveControl(conn, reader)
|
|
case daemon.ModeMCP:
|
|
s.serveMCP(conn, reader)
|
|
}
|
|
}
|
|
|
|
func (s *stubDaemonServer) serveControl(conn net.Conn, reader *bufio.Reader) {
|
|
for {
|
|
line, err := reader.ReadBytes('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
var req daemon.ControlRequest
|
|
if err := json.Unmarshal(line, &req); err != nil {
|
|
return
|
|
}
|
|
if req.Kind != daemon.ControlStatus {
|
|
_ = daemon.WriteJSONLine(conn, daemon.ControlResponse{OK: false, ErrorCode: "unsupported"})
|
|
continue
|
|
}
|
|
st := daemon.StatusResponse{Version: "stub", Ready: true}
|
|
for _, p := range s.trackedRepos {
|
|
st.TrackedRepos = append(st.TrackedRepos, daemon.TrackedRepoStatus{Path: p})
|
|
}
|
|
raw, _ := json.Marshal(st)
|
|
_ = daemon.WriteJSONLine(conn, daemon.ControlResponse{OK: true, Result: raw})
|
|
}
|
|
}
|
|
|
|
func (s *stubDaemonServer) serveMCP(conn net.Conn, reader *bufio.Reader) {
|
|
for {
|
|
line, err := reader.ReadBytes('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
var frame struct {
|
|
ID json.RawMessage `json:"id"`
|
|
Method string `json:"method"`
|
|
Params struct {
|
|
Name string `json:"name"`
|
|
Arguments map[string]any `json:"arguments"`
|
|
} `json:"params"`
|
|
}
|
|
if err := json.Unmarshal(line, &frame); err != nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
s.lastTool = frame.Params.Name
|
|
s.lastArgs = frame.Params.Arguments
|
|
mcpErr := s.mcpError
|
|
result := s.mcpResult
|
|
s.mu.Unlock()
|
|
|
|
if mcpErr != nil {
|
|
resp := map[string]any{
|
|
"jsonrpc": "2.0",
|
|
"id": json.RawMessage(frame.ID),
|
|
"error": map[string]any{
|
|
"code": mcpErr.Code,
|
|
"message": mcpErr.Message,
|
|
"data": map[string]any{"error_code": mcpErr.ErrorCode},
|
|
},
|
|
}
|
|
b, _ := json.Marshal(resp)
|
|
_, _ = conn.Write(append(b, '\n'))
|
|
continue
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"jsonrpc": "2.0",
|
|
"id": json.RawMessage(frame.ID),
|
|
"result": map[string]any{
|
|
"content": []map[string]any{{"type": "text", "text": string(result)}},
|
|
},
|
|
}
|
|
b, _ := json.Marshal(resp)
|
|
_, _ = conn.Write(append(b, '\n'))
|
|
}
|
|
}
|
|
|
|
func (s *stubDaemonServer) seenTool() (string, map[string]any) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.lastTool, s.lastArgs
|
|
}
|
|
|
|
// TestResolveExecutor_DaemonFirst asserts resolveExecutor is daemon-first:
|
|
// when a warm daemon owns the repo it returns a daemonExecutor that relays
|
|
// the tool call over the daemon's MCP channel (the same warm graph the
|
|
// editor proxies hit). When no daemon is reachable, or the daemon does not
|
|
// track the repo, it returns ErrNoExecutor so the caller can fall back.
|
|
func TestResolveExecutor_DaemonFirst(t *testing.T) {
|
|
repo := t.TempDir()
|
|
|
|
t.Run("warm daemon owns repo -> daemonExecutor relays", func(t *testing.T) {
|
|
stub := startStubDaemon(t, []string{repo})
|
|
stub.mcpResult = json.RawMessage(`{"total":7}`)
|
|
|
|
exec, err := resolveExecutor(repo)
|
|
if err != nil {
|
|
t.Fatalf("resolveExecutor on a warm daemon that owns the repo: %v", err)
|
|
}
|
|
defer exec.Close()
|
|
|
|
if _, ok := exec.(*daemonExecutor); !ok {
|
|
t.Fatalf("daemon-first path must return a *daemonExecutor, got %T", exec)
|
|
}
|
|
|
|
out, err := exec.CallTool(context.Background(), "graph_stats", map[string]any{"x": "y"})
|
|
if err != nil {
|
|
t.Fatalf("relay CallTool: %v", err)
|
|
}
|
|
var payload struct {
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.Unmarshal(out, &payload); err != nil {
|
|
t.Fatalf("relayed payload should be the tool json: %v (%s)", err, out)
|
|
}
|
|
if payload.Total != 7 {
|
|
t.Fatalf("relayed payload total = %d, want 7", payload.Total)
|
|
}
|
|
// The tool name and the caller args really made it to the daemon.
|
|
tool, args := stub.seenTool()
|
|
if tool != "graph_stats" {
|
|
t.Fatalf("daemon saw tool %q, want graph_stats", tool)
|
|
}
|
|
if args["x"] != "y" {
|
|
t.Fatalf("caller args must be relayed, daemon saw %v", args)
|
|
}
|
|
})
|
|
|
|
t.Run("no daemon reachable -> ErrNoExecutor", func(t *testing.T) {
|
|
// Point the socket env at a path with no listener.
|
|
t.Setenv("GORTEX_DAEMON_SOCKET", filepath.Join(t.TempDir(), "dead.sock"))
|
|
_, err := resolveExecutor(repo)
|
|
if !errors.Is(err, ErrNoExecutor) {
|
|
t.Fatalf("a dead socket must yield ErrNoExecutor, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("daemon up but does not own repo -> ErrNoExecutor", func(t *testing.T) {
|
|
// The daemon tracks some other tree, not our repo.
|
|
startStubDaemon(t, []string{filepath.Join(t.TempDir(), "elsewhere")})
|
|
_, err := resolveExecutor(repo)
|
|
if !errors.Is(err, ErrNoExecutor) {
|
|
t.Fatalf("an untracked repo must yield ErrNoExecutor, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestDaemonExecutor_ErrDistinct asserts the daemonExecutor relay
|
|
// distinguishes the daemon's typed refusals (ErrRepoNotTracked) and the
|
|
// daemon-unavailable case (ErrNoExecutor) from a genuine tool error, which
|
|
// is surfaced verbatim. This is the distinction a CLI command relies on to
|
|
// decide whether to fall back or fail.
|
|
func TestDaemonExecutor_ErrDistinct(t *testing.T) {
|
|
repo := t.TempDir()
|
|
|
|
t.Run("repo_not_tracked maps to ErrRepoNotTracked", func(t *testing.T) {
|
|
stub := startStubDaemon(t, []string{repo})
|
|
stub.mcpError = &stubRPCError{Code: -32000, Message: "repository not tracked", ErrorCode: "repo_not_tracked"}
|
|
|
|
exec, err := resolveExecutor(repo)
|
|
if err != nil {
|
|
t.Fatalf("resolveExecutor: %v", err)
|
|
}
|
|
defer exec.Close()
|
|
|
|
_, callErr := exec.CallTool(context.Background(), "search_symbols", nil)
|
|
if !errors.Is(callErr, ErrRepoNotTracked) {
|
|
t.Fatalf("repo_not_tracked must map to ErrRepoNotTracked, got %v", callErr)
|
|
}
|
|
// It must NOT be confused with a generic tool error or no-executor.
|
|
if errors.Is(callErr, ErrNoExecutor) {
|
|
t.Fatalf("ErrRepoNotTracked must be distinct from ErrNoExecutor")
|
|
}
|
|
})
|
|
|
|
t.Run("real tool error surfaces verbatim", func(t *testing.T) {
|
|
stub := startStubDaemon(t, []string{repo})
|
|
stub.mcpError = &stubRPCError{Code: -32602, Message: "bad symbol id"}
|
|
|
|
exec, err := resolveExecutor(repo)
|
|
if err != nil {
|
|
t.Fatalf("resolveExecutor: %v", err)
|
|
}
|
|
defer exec.Close()
|
|
|
|
_, callErr := exec.CallTool(context.Background(), "get_symbol", nil)
|
|
if callErr == nil {
|
|
t.Fatal("a real tool error must surface, got nil")
|
|
}
|
|
if errors.Is(callErr, ErrRepoNotTracked) || errors.Is(callErr, ErrNoExecutor) {
|
|
t.Fatalf("a real tool error must not collapse to a sentinel, got %v", callErr)
|
|
}
|
|
if callErr.Error() != "bad symbol id" {
|
|
t.Fatalf("real error must surface verbatim, got %q", callErr.Error())
|
|
}
|
|
})
|
|
|
|
t.Run("ErrNoExecutor is its own sentinel", func(t *testing.T) {
|
|
// No daemon at all -> the resolve step (not the call step) yields
|
|
// ErrNoExecutor, which is a distinct sentinel from a tool error.
|
|
t.Setenv("GORTEX_DAEMON_SOCKET", filepath.Join(t.TempDir(), "absent.sock"))
|
|
_, err := resolveExecutor(repo)
|
|
if !errors.Is(err, ErrNoExecutor) {
|
|
t.Fatalf("want ErrNoExecutor, got %v", err)
|
|
}
|
|
if errors.Is(err, ErrRepoNotTracked) {
|
|
t.Fatalf("ErrNoExecutor must be distinct from ErrRepoNotTracked")
|
|
}
|
|
})
|
|
}
|