Files
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

276 lines
9.2 KiB
Go

package mcp
import (
"context"
"testing"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/graph"
)
// stateWithViewed builds a sessionState whose working set carries
// the supplied viewed symbols and files. Used to drive the
// intersect logic without standing up the full sessionMap.
func stateWithViewed(symbols, files, modified []string) *sessionState {
ss := newSessionState()
for _, s := range symbols {
ss.recordSymbol(s)
}
for _, f := range files {
ss.recordFile(f)
}
for _, m := range modified {
ss.recordModified(m)
}
return ss
}
// TestStaleRefsBroadcaster_NoSubscribers — symbol change with no
// subscribers is a no-op.
func TestStaleRefsBroadcaster_NoSubscribers(t *testing.T) {
fake := &fakeSpecificSender{}
b := newStaleRefsBroadcaster(fake, nil, newSessionState(), zap.NewNop())
b.handleSymbolChange("/x.go",
[]*graph.Node{{ID: "x.go::Foo", Kind: graph.KindFunction}},
[]*graph.Node{},
)
assert.Empty(t, fake.snapshot())
}
// TestStaleRefsBroadcaster_RemovedSymbol_InWorkingSet — a session
// that viewed Foo sees it in `removed_symbols` when Foo disappears.
func TestStaleRefsBroadcaster_RemovedSymbol_InWorkingSet(t *testing.T) {
fake := &fakeSpecificSender{}
defaults := stateWithViewed([]string{"x.go::Foo"}, nil, nil)
b := newStaleRefsBroadcaster(fake, nil, defaults, zap.NewNop())
b.subscribe("A")
b.handleSymbolChange("/x.go",
[]*graph.Node{{ID: "x.go::Foo", Kind: graph.KindFunction}},
[]*graph.Node{},
)
calls := fake.snapshot()
require.Len(t, calls, 1)
assert.Equal(t, "/x.go", calls[0].params["path"])
assert.Equal(t, "file:///x.go", calls[0].params["uri"])
removed := calls[0].params["removed_symbols"].([]string)
require.Len(t, removed, 1)
assert.Equal(t, "x.go::Foo", removed[0])
}
// TestStaleRefsBroadcaster_ChangedSignature_InWorkingSet — a
// session that viewed Foo sees it in `changed_signatures` when the
// signature mutates.
func TestStaleRefsBroadcaster_ChangedSignature_InWorkingSet(t *testing.T) {
fake := &fakeSpecificSender{}
defaults := stateWithViewed([]string{"x.go::Foo"}, nil, nil)
b := newStaleRefsBroadcaster(fake, nil, defaults, zap.NewNop())
b.subscribe("A")
oldNode := &graph.Node{ID: "x.go::Foo", Kind: graph.KindFunction, Meta: map[string]any{"signature": "func Foo() int"}}
newNode := &graph.Node{ID: "x.go::Foo", Kind: graph.KindFunction, Meta: map[string]any{"signature": "func Foo(x string) int"}}
b.handleSymbolChange("/x.go",
[]*graph.Node{oldNode},
[]*graph.Node{newNode},
)
calls := fake.snapshot()
require.Len(t, calls, 1)
changed := calls[0].params["changed_signatures"].([]string)
require.Len(t, changed, 1)
assert.Equal(t, "x.go::Foo", changed[0])
}
// TestStaleRefsBroadcaster_OutOfWorkingSet_Skipped — a session
// that never touched Foo sees no notification when Foo changes.
func TestStaleRefsBroadcaster_OutOfWorkingSet_Skipped(t *testing.T) {
fake := &fakeSpecificSender{}
defaults := stateWithViewed([]string{"y.go::Bar"}, nil, nil)
b := newStaleRefsBroadcaster(fake, nil, defaults, zap.NewNop())
b.subscribe("A")
b.handleSymbolChange("/x.go",
[]*graph.Node{{ID: "x.go::Foo", Kind: graph.KindFunction}},
[]*graph.Node{},
)
assert.Empty(t, fake.snapshot(), "Foo is not in the session's working set")
}
// TestStaleRefsBroadcaster_ViewedFile_FiresWithoutSymbolHit — when
// the session has viewed the file but no specific symbol matches,
// it still gets a notification with viewed_file=true so it knows
// the file it cares about churned.
func TestStaleRefsBroadcaster_ViewedFile_FiresWithoutSymbolHit(t *testing.T) {
fake := &fakeSpecificSender{}
defaults := stateWithViewed(nil, []string{"/x.go"}, nil)
b := newStaleRefsBroadcaster(fake, nil, defaults, zap.NewNop())
b.subscribe("A")
b.handleSymbolChange("/x.go",
[]*graph.Node{{ID: "x.go::Foo", Kind: graph.KindFunction}},
[]*graph.Node{},
)
calls := fake.snapshot()
require.Len(t, calls, 1)
assert.Equal(t, true, calls[0].params["viewed_file"])
assert.Empty(t, calls[0].params["removed_symbols"])
}
// TestStaleRefsBroadcaster_ModifiedFile_TreatedAsViewed — modified
// files behave like viewed files for the staleness check.
func TestStaleRefsBroadcaster_ModifiedFile_TreatedAsViewed(t *testing.T) {
fake := &fakeSpecificSender{}
defaults := stateWithViewed(nil, nil, []string{"/x.go"})
b := newStaleRefsBroadcaster(fake, nil, defaults, zap.NewNop())
b.subscribe("A")
b.handleSymbolChange("/x.go",
[]*graph.Node{{ID: "x.go::Foo", Kind: graph.KindFunction}},
[]*graph.Node{},
)
calls := fake.snapshot()
require.Len(t, calls, 1)
assert.Equal(t, true, calls[0].params["viewed_file"])
}
// TestStaleRefsBroadcaster_DeltaFilter — identical
// (removed, changed, viewed_file) tuples are suppressed.
func TestStaleRefsBroadcaster_DeltaFilter(t *testing.T) {
fake := &fakeSpecificSender{}
defaults := stateWithViewed([]string{"x.go::Foo"}, nil, nil)
b := newStaleRefsBroadcaster(fake, nil, defaults, zap.NewNop())
b.subscribe("A")
for i := 0; i < 3; i++ {
b.handleSymbolChange("/x.go",
[]*graph.Node{{ID: "x.go::Foo", Kind: graph.KindFunction}},
[]*graph.Node{},
)
}
assert.Len(t, fake.snapshot(), 1, "delta filter must suppress repeated identical change reports")
}
// TestStaleRefsBroadcaster_DeltaFilter_ResetOnResubscribe — when a
// session unsubscribes and resubscribes the per-session delta hash
// is cleared so a fresh identical change re-fires.
func TestStaleRefsBroadcaster_DeltaFilter_ResetOnResubscribe(t *testing.T) {
fake := &fakeSpecificSender{}
defaults := stateWithViewed([]string{"x.go::Foo"}, nil, nil)
b := newStaleRefsBroadcaster(fake, nil, defaults, zap.NewNop())
b.subscribe("A")
b.handleSymbolChange("/x.go",
[]*graph.Node{{ID: "x.go::Foo", Kind: graph.KindFunction}},
[]*graph.Node{},
)
require.Len(t, fake.snapshot(), 1)
b.unsubscribe("A")
b.subscribe("A")
b.handleSymbolChange("/x.go",
[]*graph.Node{{ID: "x.go::Foo", Kind: graph.KindFunction}},
[]*graph.Node{},
)
assert.Len(t, fake.snapshot(), 2, "resubscribe must clear the per-session delta cache")
}
// TestStaleRefsBroadcaster_PerSessionFilter — two sessions with
// disjoint working sets receive disjoint notifications from the
// same change event.
func TestStaleRefsBroadcaster_PerSessionFilter(t *testing.T) {
fake := &fakeSpecificSender{}
sessions := newSessionMap()
a := sessions.get("A")
a.session.recordSymbol("x.go::Foo")
b := sessions.get("B")
b.session.recordSymbol("x.go::Bar")
br := newStaleRefsBroadcaster(fake, sessions, newSessionState(), zap.NewNop())
br.subscribe("A")
br.subscribe("B")
br.handleSymbolChange("/x.go",
[]*graph.Node{
{ID: "x.go::Foo", Kind: graph.KindFunction},
{ID: "x.go::Bar", Kind: graph.KindFunction},
},
[]*graph.Node{},
)
calls := fake.snapshot()
require.Len(t, calls, 2)
bySession := make(map[string][]string)
for _, c := range calls {
bySession[c.sessionID] = c.params["removed_symbols"].([]string)
}
assert.Equal(t, []string{"x.go::Foo"}, bySession["A"])
assert.Equal(t, []string{"x.go::Bar"}, bySession["B"])
}
// TestDiffSymbolSets_KindFileImportIgnored — file / import nodes
// are not user-authored symbols and must never appear in the diff.
func TestDiffSymbolSets_KindFileImportIgnored(t *testing.T) {
removed, changed := diffSymbolSets(
[]*graph.Node{
{ID: "x.go", Kind: graph.KindFile},
{ID: "x.go::import:os", Kind: graph.KindImport},
{ID: "x.go::Foo", Kind: graph.KindFunction},
},
[]*graph.Node{},
)
assert.Equal(t, []string{"x.go::Foo"}, removed)
assert.Empty(t, changed)
}
// TestStaleRefsBroadcaster_Unsubscribe — after unsubscribe a
// session stops receiving notifications.
func TestStaleRefsBroadcaster_Unsubscribe(t *testing.T) {
fake := &fakeSpecificSender{}
defaults := stateWithViewed([]string{"x.go::Foo"}, nil, nil)
b := newStaleRefsBroadcaster(fake, nil, defaults, zap.NewNop())
b.subscribe("A")
b.unsubscribe("A")
b.handleSymbolChange("/x.go",
[]*graph.Node{{ID: "x.go::Foo", Kind: graph.KindFunction}},
[]*graph.Node{},
)
assert.Empty(t, fake.snapshot())
}
// TestServer_ReleaseSession_UnsubscribesStaleRefs — Server cleanup
// drops stale_refs subscribers.
func TestServer_ReleaseSession_UnsubscribesStaleRefs(t *testing.T) {
fake := &fakeSpecificSender{}
srv := &Server{staleRefsBroadcaster: newStaleRefsBroadcaster(fake, nil, newSessionState(), zap.NewNop())}
srv.staleRefsBroadcaster.subscribe("A")
require.Equal(t, 1, srv.staleRefsBroadcaster.subscriberCount())
srv.ReleaseSession("A")
assert.Equal(t, 0, srv.staleRefsBroadcaster.subscriberCount())
}
// TestRegisterStaleRefsTools_Wiring — tool handlers operate on the
// broadcaster.
func TestRegisterStaleRefsTools_Wiring(t *testing.T) {
fake := &fakeSpecificSender{}
srv := &Server{staleRefsBroadcaster: newStaleRefsBroadcaster(fake, nil, newSessionState(), zap.NewNop())}
req := mcp.CallToolRequest{}
res, err := srv.handleSubscribeStaleRefs(context.Background(), req)
require.NoError(t, err)
require.NotNil(t, res)
assert.Equal(t, 1, srv.staleRefsBroadcaster.subscriberCount())
res, err = srv.handleUnsubscribeStaleRefs(context.Background(), req)
require.NoError(t, err)
require.NotNil(t, res)
assert.Equal(t, 0, srv.staleRefsBroadcaster.subscriberCount())
}