chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user