Files
zzet--gortex/cmd/gortex/mcp_legacy_flags_test.go
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

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)
}
})
}