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
328 lines
11 KiB
Go
328 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// newCallTestCmd builds a fresh call command bound to an out/err buffer and
|
|
// resets the package-level flag state to defaults so tests don't leak.
|
|
func newCallTestCmd(t *testing.T) (*cobra.Command, *bytes.Buffer) {
|
|
t.Helper()
|
|
callIndex = "."
|
|
callJSON = ""
|
|
callJSONFile = ""
|
|
callArgs = nil
|
|
callFormat = "json"
|
|
callDry = false
|
|
callQuiet = false
|
|
|
|
buf := &bytes.Buffer{}
|
|
cmd := &cobra.Command{Use: "call", RunE: runCall}
|
|
cmd.SetOut(buf)
|
|
cmd.SetErr(buf)
|
|
return cmd, buf
|
|
}
|
|
|
|
// TestCoerceScalar exhaustively covers the deterministic --arg value coercion.
|
|
func TestCoerceScalar(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
want any
|
|
}{
|
|
{"true", true},
|
|
{"false", false},
|
|
{"null", nil},
|
|
{"42", int64(42)},
|
|
{"-7", int64(-7)},
|
|
{"3.14", 3.14},
|
|
{"", ""},
|
|
{"hello", "hello"},
|
|
{"1.0.0", "1.0.0"}, // not a number — stays a string
|
|
{`[1,2,3]`, []any{float64(1), float64(2), float64(3)}},
|
|
{`{"k":"v"}`, map[string]any{"k": "v"}},
|
|
{`[broken`, `[broken`}, // invalid JSON array — falls back to literal string
|
|
}
|
|
for _, c := range cases {
|
|
got := coerceScalar(c.in)
|
|
if !reflect.DeepEqual(got, c.want) {
|
|
t.Errorf("coerceScalar(%q) = %#v (%T), want %#v (%T)", c.in, got, got, c.want, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCoerceArg covers the key/value split, the := walrus raw-JSON form, and
|
|
// error cases.
|
|
func TestCoerceArg(t *testing.T) {
|
|
t.Run("plain string", func(t *testing.T) {
|
|
k, v, err := coerceArg("name=foo")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "name", k)
|
|
require.Equal(t, "foo", v)
|
|
})
|
|
t.Run("bool", func(t *testing.T) {
|
|
k, v, err := coerceArg("flag=true")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "flag", k)
|
|
require.Equal(t, true, v)
|
|
})
|
|
t.Run("int", func(t *testing.T) {
|
|
_, v, err := coerceArg("limit=50")
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(50), v)
|
|
})
|
|
t.Run("empty value", func(t *testing.T) {
|
|
k, v, err := coerceArg("note=")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "note", k)
|
|
require.Equal(t, "", v)
|
|
})
|
|
t.Run("walrus forces raw string", func(t *testing.T) {
|
|
// version:="1.0" must stay the string "1.0", not the number 1.0.
|
|
k, v, err := coerceArg(`version:="1.0"`)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "version", k)
|
|
require.Equal(t, "1.0", v)
|
|
})
|
|
t.Run("walrus parses raw JSON object", func(t *testing.T) {
|
|
k, v, err := coerceArg(`opts:={"a":1}`)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "opts", k)
|
|
require.Equal(t, map[string]any{"a": float64(1)}, v)
|
|
})
|
|
t.Run("walrus invalid JSON errors", func(t *testing.T) {
|
|
_, _, err := coerceArg(`x:=not-json`)
|
|
require.Error(t, err)
|
|
})
|
|
t.Run("missing equals errors", func(t *testing.T) {
|
|
_, _, err := coerceArg("noequals")
|
|
require.Error(t, err)
|
|
})
|
|
t.Run("empty key errors", func(t *testing.T) {
|
|
_, _, err := coerceArg("=value")
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
|
|
// TestLowerCallArgs_Precedence asserts the three precedence layers: base JSON,
|
|
// inline JSON merged over it, and --arg merged on top (last wins per key).
|
|
func TestLowerCallArgs_Precedence(t *testing.T) {
|
|
cmd, _ := newCallTestCmd(t)
|
|
|
|
t.Run("inline json plus arg override", func(t *testing.T) {
|
|
obj, err := lowerCallArgs(cmd, `{"a":1,"b":2}`, "", []string{"b=3"})
|
|
require.NoError(t, err)
|
|
require.Equal(t, map[string]any{"a": float64(1), "b": int64(3)}, obj)
|
|
})
|
|
|
|
t.Run("later arg replaces earlier same key", func(t *testing.T) {
|
|
obj, err := lowerCallArgs(cmd, "", "", []string{"k=first", "k=second"})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "second", obj["k"])
|
|
})
|
|
|
|
t.Run("arg over inline json over file base", func(t *testing.T) {
|
|
// Write a base file, then merge inline JSON and --arg over it.
|
|
base := writeTempJSON(t, `{"a":"file","b":"file","c":"file"}`)
|
|
obj, err := lowerCallArgs(cmd, `{"b":"inline","c":"inline"}`, base, []string{"c=arg"})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "file", obj["a"]) // only in the file base
|
|
require.Equal(t, "inline", obj["b"]) // inline beats file
|
|
require.Equal(t, "arg", obj["c"]) // arg beats inline + file
|
|
})
|
|
}
|
|
|
|
// TestLowerCallArgs_StdinBase asserts --json - reads the base object from stdin.
|
|
func TestLowerCallArgs_StdinBase(t *testing.T) {
|
|
cmd, _ := newCallTestCmd(t)
|
|
cmd.SetIn(strings.NewReader(`{"from":"stdin","n":1}`))
|
|
obj, err := lowerCallArgs(cmd, "-", "", []string{"n=2"})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "stdin", obj["from"])
|
|
require.Equal(t, int64(2), obj["n"])
|
|
}
|
|
|
|
// TestLowerCallArgs_NonObjectRejected asserts a non-object base JSON is refused.
|
|
func TestLowerCallArgs_NonObjectRejected(t *testing.T) {
|
|
cmd, _ := newCallTestCmd(t)
|
|
_, err := lowerCallArgs(cmd, `[1,2,3]`, "", nil)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "JSON object")
|
|
}
|
|
|
|
// TestCallDry_NoDaemon asserts --dry prints the lowered object and the target
|
|
// tool WITHOUT invoking the daemon (the stub records any call).
|
|
func TestCallDry_NoDaemon(t *testing.T) {
|
|
orig := callDaemonTool
|
|
t.Cleanup(func() { callDaemonTool = orig })
|
|
|
|
called := false
|
|
callDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) {
|
|
called = true
|
|
return nil, nil
|
|
}
|
|
|
|
cmd, buf := newCallTestCmd(t)
|
|
callDry = true
|
|
callJSON = `{"id":"pkg/foo.go::Bar"}`
|
|
callArgs = []string{"depth=2", "verbose=true"}
|
|
|
|
require.NoError(t, runCall(cmd, []string{"get_callers"}))
|
|
require.False(t, called, "--dry must NOT invoke the daemon (not even tool_profile)")
|
|
|
|
out := buf.String()
|
|
require.Contains(t, out, "tool: get_callers")
|
|
// The lowered object is printed as indented JSON.
|
|
require.Contains(t, out, `"id": "pkg/foo.go::Bar"`)
|
|
require.Contains(t, out, `"depth": 2`)
|
|
require.Contains(t, out, `"verbose": true`)
|
|
}
|
|
|
|
// cannedToolProfileJSON is a minimal tool_profile response with a descriptors
|
|
// array covering a read tool and a mutating tool.
|
|
const cannedToolProfileJSON = `{
|
|
"live": ["search_symbols", "get_callers"],
|
|
"deferred": ["edit_file", "rename_symbol"],
|
|
"descriptors": [
|
|
{"name":"search_symbols","category":"nav","mutating":false,"presets":["core","nav","readonly"],"summary":"BM25 symbol search."},
|
|
{"name":"get_callers","category":"nav","mutating":false,"presets":["core","nav","readonly"],"summary":"Who calls a function."},
|
|
{"name":"edit_file","category":"edit","mutating":true,"presets":["core","edit"],"summary":"Edit a file."},
|
|
{"name":"rename_symbol","category":"edit","mutating":true,"presets":["edit"],"summary":"Rename across files."}
|
|
]
|
|
}`
|
|
|
|
// TestCall_UnknownToolSuggestsNearest asserts that, when a daemon is reachable
|
|
// and returns a catalog, an unknown tool name yields an error listing the
|
|
// nearest matches plus the `gortex tools search` hint.
|
|
func TestCall_UnknownToolSuggestsNearest(t *testing.T) {
|
|
orig := callDaemonTool
|
|
t.Cleanup(func() { callDaemonTool = orig })
|
|
|
|
var calledTools []string
|
|
callDaemonTool = func(_ string, tool string, _ map[string]any) (json.RawMessage, error) {
|
|
calledTools = append(calledTools, tool)
|
|
if tool == "tool_profile" {
|
|
return json.RawMessage(cannedToolProfileJSON), nil
|
|
}
|
|
t.Fatalf("must not call %q after a failed name validation", tool)
|
|
return nil, nil
|
|
}
|
|
|
|
cmd, _ := newCallTestCmd(t)
|
|
err := runCall(cmd, []string{"get_caller"}) // typo of get_callers
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "unknown tool")
|
|
require.Contains(t, err.Error(), "get_callers", "should suggest the nearest match")
|
|
require.Contains(t, err.Error(), "gortex tools search")
|
|
require.Equal(t, []string{"tool_profile"}, calledTools, "only the catalog fetch should happen")
|
|
}
|
|
|
|
// TestCall_KnownMutatingToolWarns asserts a known mutating tool both forwards
|
|
// the call and prints the stderr write note (unless --quiet).
|
|
func TestCall_KnownMutatingToolWarns(t *testing.T) {
|
|
orig := callDaemonTool
|
|
t.Cleanup(func() { callDaemonTool = orig })
|
|
|
|
callDaemonTool = func(_ string, tool string, args map[string]any) (json.RawMessage, error) {
|
|
if tool == "tool_profile" {
|
|
return json.RawMessage(cannedToolProfileJSON), nil
|
|
}
|
|
require.Equal(t, "edit_file", tool)
|
|
require.Equal(t, "json", args["format"])
|
|
return json.RawMessage(`{"status":"ok"}`), nil
|
|
}
|
|
|
|
cmd, buf := newCallTestCmd(t)
|
|
require.NoError(t, runCall(cmd, []string{"edit_file"}))
|
|
out := buf.String()
|
|
require.Contains(t, out, "note: edit_file writes")
|
|
require.Contains(t, out, `"status": "ok"`)
|
|
}
|
|
|
|
// TestCall_QuietSuppressesMutatingNote asserts --quiet hides the write note.
|
|
func TestCall_QuietSuppressesMutatingNote(t *testing.T) {
|
|
orig := callDaemonTool
|
|
t.Cleanup(func() { callDaemonTool = orig })
|
|
callDaemonTool = func(_ string, tool string, _ map[string]any) (json.RawMessage, error) {
|
|
if tool == "tool_profile" {
|
|
return json.RawMessage(cannedToolProfileJSON), nil
|
|
}
|
|
return json.RawMessage(`{}`), nil
|
|
}
|
|
|
|
cmd, buf := newCallTestCmd(t)
|
|
callQuiet = true
|
|
require.NoError(t, runCall(cmd, []string{"edit_file"}))
|
|
require.NotContains(t, buf.String(), "note:")
|
|
}
|
|
|
|
// TestCall_NoDaemonSkipsValidation asserts that when the catalog fetch fails
|
|
// (no daemon), validation is skipped and the normal call path runs — surfacing
|
|
// whatever the call returns rather than an "unknown tool" error.
|
|
func TestCall_NoDaemonSkipsValidation(t *testing.T) {
|
|
orig := callDaemonTool
|
|
t.Cleanup(func() { callDaemonTool = orig })
|
|
|
|
callDaemonTool = func(_ string, tool string, _ map[string]any) (json.RawMessage, error) {
|
|
if tool == "tool_profile" {
|
|
return nil, daemonRequiredErr(".") // no catalog available
|
|
}
|
|
require.Equal(t, "some_tool", tool, "the call must proceed despite the catalog miss")
|
|
return json.RawMessage(`{"ok":true}`), nil
|
|
}
|
|
|
|
cmd, buf := newCallTestCmd(t)
|
|
require.NoError(t, runCall(cmd, []string{"some_tool"}))
|
|
require.Contains(t, buf.String(), `"ok": true`)
|
|
}
|
|
|
|
// TestCall_FormatGCXVerbatim asserts gcx/toon output is printed verbatim, not
|
|
// re-indented through emitDaemonJSON.
|
|
func TestCall_FormatGCXVerbatim(t *testing.T) {
|
|
orig := callDaemonTool
|
|
t.Cleanup(func() { callDaemonTool = orig })
|
|
|
|
callDaemonTool = func(_ string, tool string, args map[string]any) (json.RawMessage, error) {
|
|
if tool == "tool_profile" {
|
|
return json.RawMessage(cannedToolProfileJSON), nil
|
|
}
|
|
require.Equal(t, "gcx", args["format"], "the chosen format must be forwarded")
|
|
return json.RawMessage("GCX1|results|...\n"), nil
|
|
}
|
|
|
|
cmd, buf := newCallTestCmd(t)
|
|
callFormat = "gcx"
|
|
require.NoError(t, runCall(cmd, []string{"search_symbols"}))
|
|
require.Equal(t, "GCX1|results|...\n", buf.String())
|
|
}
|
|
|
|
// TestCall_DaemonRequired asserts that, with no daemon running and no stub, the
|
|
// real call path returns the actionable daemon-required error (mirrors
|
|
// query_daemon_required_test.go). The catalog fetch fails first (no daemon), so
|
|
// validation is skipped and the call itself surfaces the error.
|
|
func TestCall_DaemonRequired(t *testing.T) {
|
|
// Point at a temp dir no daemon could possibly track so resolveExecutor
|
|
// returns ErrNoExecutor -> daemonRequiredErr.
|
|
cmd, _ := newCallTestCmd(t)
|
|
callIndex = t.TempDir()
|
|
err := runCall(cmd, []string{"search_symbols"})
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "gortex track")
|
|
}
|
|
|
|
// writeTempJSON writes content to a temp file and returns its path.
|
|
func writeTempJSON(t *testing.T, content string) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
path := dir + "/base.json"
|
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
|
|
return path
|
|
}
|