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
248 lines
8.1 KiB
Go
248 lines
8.1 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// newLegacyMCPCmd builds a throwaway cobra command carrying the same legacy
|
|
// flag surface `gortex mcp` exposes (--index/--watch/--proxy/--no-daemon),
|
|
// so warnLegacyMCPFlags can be driven without mutating the real mcpCmd.
|
|
func newLegacyMCPCmd() *cobra.Command {
|
|
var (
|
|
index string
|
|
watch bool
|
|
proxy bool
|
|
noDaemon bool
|
|
)
|
|
cmd := &cobra.Command{Use: "mcp", RunE: func(*cobra.Command, []string) error { return nil }}
|
|
cmd.Flags().StringVar(&index, "index", "", "repository path to index on startup")
|
|
cmd.Flags().BoolVar(&watch, "watch", false, "keep graph in sync with filesystem changes")
|
|
cmd.Flags().BoolVar(&proxy, "proxy", false, "require a running daemon and proxy through it")
|
|
cmd.Flags().BoolVar(&noDaemon, "no-daemon", false, "force embedded server")
|
|
return cmd
|
|
}
|
|
|
|
// captureLegacyStderr swaps os.Stderr and os.Stdout for pipes, runs fn,
|
|
// and returns whatever fn wrote to each. warnLegacyMCPFlags writes to
|
|
// os.Stderr directly (not cmd.ErrOrStderr), so the swap is the only way to
|
|
// observe its output.
|
|
func captureLegacyStderr(t *testing.T, fn func()) (stderr, stdout string) {
|
|
t.Helper()
|
|
errR, errW, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatalf("stderr pipe: %v", err)
|
|
}
|
|
outR, outW, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatalf("stdout pipe: %v", err)
|
|
}
|
|
origErr, origOut := os.Stderr, os.Stdout
|
|
os.Stderr, os.Stdout = errW, outW
|
|
defer func() { os.Stderr, os.Stdout = origErr, origOut }()
|
|
|
|
fn()
|
|
|
|
_ = errW.Close()
|
|
_ = outW.Close()
|
|
var errBuf, outBuf bytes.Buffer
|
|
_, _ = errBuf.ReadFrom(errR)
|
|
_, _ = outBuf.ReadFrom(outR)
|
|
return errBuf.String(), outBuf.String()
|
|
}
|
|
|
|
// TestLegacyFlags_NoError asserts that each legacy `gortex mcp` flag still
|
|
// PARSES (the deprecation shims never break a stale editor config) and that
|
|
// warnLegacyMCPFlags emits exactly one stderr line per CHANGED legacy flag
|
|
// while writing nothing to stdout (stdout is the MCP JSON-RPC stream).
|
|
func TestLegacyFlags_NoError(t *testing.T) {
|
|
// Every legacy flag set at once, plus its full combination, must parse
|
|
// without a flag-parse error.
|
|
allFlagsCmd := newLegacyMCPCmd()
|
|
if err := allFlagsCmd.ParseFlags([]string{"--index", ".", "--watch", "--proxy", "--no-daemon"}); err != nil {
|
|
t.Fatalf("legacy flag combination must parse without error: %v", err)
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
argv []string
|
|
wantNotes []string // legacy flag names expected to produce a stderr note
|
|
}{
|
|
{"index only", []string{"--index", "."}, []string{"index"}},
|
|
{"watch only", []string{"--watch"}, []string{"watch"}},
|
|
{"proxy only", []string{"--proxy"}, []string{"proxy"}},
|
|
{"no-daemon only", []string{"--no-daemon"}, []string{"no-daemon"}},
|
|
{
|
|
"all four",
|
|
[]string{"--index", ".", "--watch", "--proxy", "--no-daemon"},
|
|
[]string{"index", "watch", "proxy", "no-daemon"},
|
|
},
|
|
{"none set", []string{}, nil},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cmd := newLegacyMCPCmd()
|
|
if err := cmd.ParseFlags(tc.argv); err != nil {
|
|
t.Fatalf("ParseFlags(%v) errored: %v", tc.argv, err)
|
|
}
|
|
|
|
// warnLegacyMCPFlags is guarded by a package-global so it only
|
|
// warns once per process; reset it so each case is observed.
|
|
legacyMCPFlagsWarned = false
|
|
t.Cleanup(func() { legacyMCPFlagsWarned = false })
|
|
|
|
stderr, stdout := captureLegacyStderr(t, func() {
|
|
warnLegacyMCPFlags(cmd)
|
|
})
|
|
|
|
if stdout != "" {
|
|
t.Fatalf("warnLegacyMCPFlags must write nothing to stdout (it is the JSON-RPC stream); got %q", stdout)
|
|
}
|
|
|
|
lines := nonEmptyLines(stderr)
|
|
if len(lines) != len(tc.wantNotes) {
|
|
t.Fatalf("want %d stderr note(s) %v, got %d: %q", len(tc.wantNotes), tc.wantNotes, len(lines), stderr)
|
|
}
|
|
for _, name := range tc.wantNotes {
|
|
if !legacyNoteMentions(stderr, name) {
|
|
t.Fatalf("expected a deprecation note for --%s, got: %q", name, stderr)
|
|
}
|
|
}
|
|
// Every emitted note must name a deprecated/ignored flag.
|
|
for _, ln := range lines {
|
|
if !strings.Contains(ln, "deprecated") {
|
|
t.Fatalf("stderr note is not a deprecation notice: %q", ln)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// nonEmptyLines splits s into its non-blank lines.
|
|
func nonEmptyLines(s string) []string {
|
|
var out []string
|
|
sc := bufio.NewScanner(strings.NewReader(s))
|
|
for sc.Scan() {
|
|
if strings.TrimSpace(sc.Text()) != "" {
|
|
out = append(out, sc.Text())
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// legacyNoteMentions reports whether stderr carries a note for the given
|
|
// legacy flag name (matched as the `--<name>` token).
|
|
func legacyNoteMentions(stderr, name string) bool {
|
|
return strings.Contains(stderr, "--"+name+" ")
|
|
}
|
|
|
|
// TestRunMCP_TTYvsPipe_Identical proves the `gortex mcp` mode decision is
|
|
// independent of whether stdin is a TTY or a pipe. The decision is made by
|
|
// resolveDaemonDecision -> ensureDaemonReady from daemon-presence plus
|
|
// GORTEX_AUTOSTART, never from a stdin char-device probe (the old
|
|
// shouldTryProxy heuristic is gone).
|
|
//
|
|
// Two things are asserted:
|
|
// 1. For a fixed daemon state, the resolved decision is byte-identical
|
|
// regardless of what os.Stdin points at (a pipe / non-TTY vs a regular
|
|
// file). If any stdin char-device check leaked into the decision path,
|
|
// swapping stdin would change the result.
|
|
// 2. The decision tracks daemon presence + autostart only — a live daemon
|
|
// yields daemonReady under any stdin; autostart-off with no daemon
|
|
// yields daemonUnavailable under any stdin.
|
|
func TestRunMCP_TTYvsPipe_Identical(t *testing.T) {
|
|
defer restoreSeams()
|
|
|
|
// A regular file stands in for "not a char device" and a pipe stands in
|
|
// for the piped-stdin case an editor uses; neither is a TTY in CI. The
|
|
// point is that the decision must not vary across these.
|
|
regularFile, err := os.CreateTemp(t.TempDir(), "stdin-*")
|
|
if err != nil {
|
|
t.Fatalf("temp file: %v", err)
|
|
}
|
|
defer regularFile.Close()
|
|
pipeR, pipeW, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatalf("pipe: %v", err)
|
|
}
|
|
defer pipeR.Close()
|
|
defer pipeW.Close()
|
|
|
|
stdins := map[string]*os.File{
|
|
"regular-file": regularFile,
|
|
"pipe": pipeR,
|
|
}
|
|
|
|
// Pin a fixed daemon state per scenario via the injectable seams, then
|
|
// resolve the decision under each stdin and assert agreement.
|
|
scenarios := []struct {
|
|
name string
|
|
running bool
|
|
autostart bool
|
|
want daemonDecision
|
|
}{
|
|
{"daemon live", true, true, daemonReady},
|
|
{"daemon live, autostart off", true, false, daemonReady},
|
|
{"no daemon, autostart off", false, false, daemonUnavailable},
|
|
}
|
|
|
|
for _, sc := range scenarios {
|
|
t.Run(sc.name, func(t *testing.T) {
|
|
isDaemonRunning = func() bool { return sc.running }
|
|
spawnDaemon = func() error { t.Fatal("no spawn expected in this scenario"); return nil }
|
|
t.Cleanup(restoreSeams)
|
|
|
|
origStdin := os.Stdin
|
|
defer func() { os.Stdin = origStdin }()
|
|
|
|
var first daemonDecision
|
|
var firstName string
|
|
i := 0
|
|
for name, f := range stdins {
|
|
os.Stdin = f
|
|
got := ensureDaemonReady(sc.autostart)
|
|
if got != sc.want {
|
|
t.Fatalf("stdin=%s: ensureDaemonReady(%v) = %d, want %d", name, sc.autostart, got, sc.want)
|
|
}
|
|
if i == 0 {
|
|
first, firstName = got, name
|
|
} else if got != first {
|
|
t.Fatalf("decision differs by stdin: stdin=%s -> %d but stdin=%s -> %d (decision leaked a stdin probe)",
|
|
firstName, first, name, got)
|
|
}
|
|
i++
|
|
}
|
|
})
|
|
}
|
|
|
|
// resolveDaemonDecision is the production entry point; confirm it too is
|
|
// stdin-agnostic by pinning autostart off and a dead daemon, then
|
|
// re-resolving under both stdins.
|
|
t.Run("resolveDaemonDecision stdin-agnostic", func(t *testing.T) {
|
|
t.Setenv("GORTEX_AUTOSTART", "0")
|
|
isDaemonRunning = func() bool { return false }
|
|
spawnDaemon = func() error { t.Fatal("autostart off must not spawn"); return nil }
|
|
t.Cleanup(restoreSeams)
|
|
|
|
origStdin := os.Stdin
|
|
defer func() { os.Stdin = origStdin }()
|
|
|
|
os.Stdin = regularFile
|
|
fromFile := resolveDaemonDecision()
|
|
os.Stdin = pipeR
|
|
fromPipe := resolveDaemonDecision()
|
|
if fromFile != fromPipe {
|
|
t.Fatalf("resolveDaemonDecision varied by stdin: file=%d pipe=%d", fromFile, fromPipe)
|
|
}
|
|
if fromFile != daemonUnavailable {
|
|
t.Fatalf("autostart off + dead daemon must be daemonUnavailable, got %d", fromFile)
|
|
}
|
|
})
|
|
}
|