Files
zzet--gortex/internal/mcp/tools_simulate_test.go
wehub-resource-sync a06f331eb8
install-script / powershell-syntax (push) Waiting to run
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
install-script / install (macos-14) (push) Waiting to run
install-script / install (ubuntu-latest) (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

445 lines
18 KiB
Go

package mcp
// Integration tests for preview_edit and simulate_chain. These hit the
// full MCP tool dispatch path (registered tools + handler) and rely on
// the shared setupOverlayServer harness defined in overlay_e2e_test.go.
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/daemon"
)
// buildSingleFileEdit constructs a WorkspaceEdit that replaces the
// full body of `path` with `newContent`. The range spans (0,0) ..
// (very-far-end), which lspPositionToByteOffset clamps to len(content).
// Used by every test below so the WorkspaceEdit JSON is constructed
// in one place.
func buildSingleFileEdit(path, newContent string) string {
edit := map[string]any{
"documentChanges": []any{
map[string]any{
"textDocument": map[string]any{"uri": path, "version": 0},
"edits": []any{
map[string]any{
"range": map[string]any{
"start": map[string]any{"line": 0, "character": 0},
"end": map[string]any{"line": 100000, "character": 0},
},
"newText": newContent,
},
},
},
},
}
b, _ := json.Marshal(edit)
return string(b)
}
// TestPreviewEdit_ToolRegistered confirms the tool wired up correctly
// after NewServer + SetOverlayManager. Without registration every
// subsequent simulation test would explode in the lookup; isolating
// the registration check lets us read the failure faster.
func TestPreviewEdit_ToolRegistered(t *testing.T) {
srv, _, _, _ := setupOverlayServer(t)
require.NotNil(t, srv.MCPServer().GetTool("preview_edit"), "preview_edit must be registered")
require.NotNil(t, srv.MCPServer().GetTool("simulate_chain"), "simulate_chain must be registered")
}
// TestPreviewEdit_RejectsInvalidJSON: malformed workspace_edit must
// surface as an MCP tool error so the agent knows to retry, not fail
// silently with an empty impact report.
func TestPreviewEdit_RejectsInvalidJSON(t *testing.T) {
srv, _, _, _ := setupOverlayServer(t)
ctx := WithSessionID(context.Background(), "test")
res := callToolByName(t, srv, ctx, "preview_edit", map[string]any{
"workspace_edit": "{not valid json}",
})
require.True(t, res.IsError, "preview_edit must reject malformed JSON")
require.Contains(t, toolText(res), "invalid workspace_edit")
}
// TestPreviewEdit_RejectsEmptyEdit: a WorkspaceEdit with no document
// changes is a no-op; treating it as a success would mislead the
// caller into thinking they had a meaningful preview.
func TestPreviewEdit_RejectsEmptyEdit(t *testing.T) {
srv, _, _, _ := setupOverlayServer(t)
ctx := WithSessionID(context.Background(), "test")
res := callToolByName(t, srv, ctx, "preview_edit", map[string]any{
"workspace_edit": `{"changes":{}}`,
})
require.True(t, res.IsError, "preview_edit must reject empty workspace_edit")
require.Contains(t, toolText(res), "no document changes")
}
// TestPreviewEdit_RenameSymbol simulates renaming a function with a
// distinctive signature so the rename heuristic can confidently pair
// the missing-base node with the added-overlay node. We can't use
// the harness's Target() because its parameterless void signature is
// the trivial shape that the heuristic deliberately rejects (every
// `func ()` would otherwise pair with every other `func ()`). The
// test seeds a fresh helper file with a distinctive signature and
// then renames it.
func TestPreviewEdit_RenameSymbol(t *testing.T) {
srv, dir, _, _ := setupOverlayServer(t)
ctx := WithSessionID(context.Background(), "test")
// Push a helper file via overlay so the base graph picks up
// `Encode(payload []byte) []byte`. Then simulate renaming it to
// `Marshal(payload []byte) []byte` — same signature shape, new
// name. The heuristic should classify this as a rename.
helperPath := filepath.Join(dir, "encoder.go")
require.NoError(t, srv.OverlayManager().RegisterWithID("seed", ""))
require.NoError(t, srv.OverlayManager().Push("seed", daemon.OverlayFile{
Path: helperPath,
Content: "package main\n\nfunc Encode(payload []byte) []byte { return payload }\n",
}, nil))
// Commit the seeded file to disk so it shows up in base on
// reindex.
require.NoError(t, writeSeedFile(helperPath,
"package main\n\nfunc Encode(payload []byte) []byte { return payload }\n"))
srv.OverlayManager().Drop("seed")
_, err := srv.indexer.IncrementalReindex(dir)
require.NoError(t, err)
srv.indexer.ResolveAll()
newContent := "package main\n\nfunc Marshal(payload []byte) []byte { return payload }\n"
res := callToolByName(t, srv, ctx, "preview_edit", map[string]any{
"workspace_edit": buildSingleFileEdit(helperPath, newContent),
"diagnostics": false, // no LSP setup in test harness
})
require.False(t, res.IsError, "preview_edit failed: %s", toolText(res))
body := toolText(res)
require.Contains(t, body, "symbols_renamed", "rename surface must be present")
require.Contains(t, body, "encoder.go::Marshal", "renamed-to symbol must surface")
require.Contains(t, body, "encoder.go::Encode", "renamed-from symbol must surface")
}
// writeSeedFile is a thin os.WriteFile wrapper kept local to this
// test file so the test imports don't grow.
func writeSeedFile(path, content string) error {
return os.WriteFile(path, []byte(content), 0o644)
}
// TestPreviewEdit_RemoveSymbol simulates a true removal: Target() is
// gone and nothing else replaces it (rename heuristic can't match).
// The broken_callers payload MUST include caller.go::Caller because
// the cross-file call edge can no longer resolve.
func TestPreviewEdit_RemoveSymbol(t *testing.T) {
srv, _, targetFile, _ := setupOverlayServer(t)
ctx := WithSessionID(context.Background(), "test")
// Replace target.go with an empty package — Target() simply
// disappears and there's no rename candidate.
newContent := "package main\n"
res := callToolByName(t, srv, ctx, "preview_edit", map[string]any{
"workspace_edit": buildSingleFileEdit(targetFile, newContent),
"diagnostics": false,
})
require.False(t, res.IsError, "preview_edit failed: %s", toolText(res))
body := toolText(res)
require.Contains(t, body, "Caller", "caller.go::Caller must show in broken_callers")
require.Contains(t, body, "target removed or renamed")
}
// TestPreviewEdit_AddOnly: adding a brand-new function alongside
// Target() should produce a non-empty symbols_added list and no
// broken callers (Target stays put, the original caller resolves).
func TestPreviewEdit_AddOnly(t *testing.T) {
srv, _, targetFile, _ := setupOverlayServer(t)
ctx := WithSessionID(context.Background(), "test")
newContent := "package main\n\nfunc Target() {}\n\nfunc NewHelper() {}\n"
res := callToolByName(t, srv, ctx, "preview_edit", map[string]any{
"workspace_edit": buildSingleFileEdit(targetFile, newContent),
"diagnostics": false,
})
require.False(t, res.IsError, "preview_edit failed: %s", toolText(res))
body := toolText(res)
require.Contains(t, body, "NewHelper", "new symbol must surface in symbols_added")
require.NotContains(t, body, "target removed or renamed",
"the unchanged Target() must not surface as broken")
}
// TestPreviewEdit_OverlayPathsReported: the response must include the
// overlay_paths field so the caller knows exactly which paths the
// simulation touched (useful when the WorkspaceEdit spans multiple
// files and the agent needs a checklist).
func TestPreviewEdit_OverlayPathsReported(t *testing.T) {
srv, _, targetFile, _ := setupOverlayServer(t)
ctx := WithSessionID(context.Background(), "test")
res := callToolByName(t, srv, ctx, "preview_edit", map[string]any{
"workspace_edit": buildSingleFileEdit(targetFile, "package main\n\nfunc Target() {}\n\nfunc Added() {}\n"),
"diagnostics": false,
})
require.False(t, res.IsError, "preview_edit failed: %s", toolText(res))
body := toolText(res)
require.Contains(t, body, "overlay_paths")
require.Contains(t, body, "touched_files")
}
// TestPreviewEdit_DoesNotMutateBase is the load-bearing isolation
// guarantee for the simulation engine — the same property the
// overlay layer enforces, but verified through the simulation tool
// surface to catch any future refactor that accidentally takes the
// promote-to-session path implicitly.
func TestPreviewEdit_DoesNotMutateBase(t *testing.T) {
srv, _, targetFile, _ := setupOverlayServer(t)
beforeIDs := baseNodeIDs(srv)
ctx := WithSessionID(context.Background(), "test")
res := callToolByName(t, srv, ctx, "preview_edit", map[string]any{
"workspace_edit": buildSingleFileEdit(targetFile, "package main\n\nfunc Renamed() {}\n"),
"diagnostics": false,
})
require.False(t, res.IsError, "preview_edit failed: %s", toolText(res))
afterIDs := baseNodeIDs(srv)
require.Equal(t, beforeIDs, afterIDs,
"base graph must be byte-identical before and after preview_edit")
}
// TestSimulateChain_AppliesStepsInOrder: a two-step chain that first
// adds Helper(), then removes Target() entirely. The response must
// include both step records — step 0 reports Helper as added, step 1
// reports the removal and the resulting broken-caller surface.
func TestSimulateChain_AppliesStepsInOrder(t *testing.T) {
srv, _, targetFile, _ := setupOverlayServer(t)
ctx := WithSessionID(context.Background(), "test")
step1 := buildSingleFileEdit(targetFile, "package main\n\nfunc Target() {}\n\nfunc Helper() {}\n")
step2 := buildSingleFileEdit(targetFile, "package main\n\nfunc Helper() {}\n")
steps := fmt.Sprintf(`[%s,%s]`, step1, step2)
res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
"steps": steps,
"diagnostics": false,
"stop_on_error": false,
})
require.False(t, res.IsError, "simulate_chain failed: %s", toolText(res))
body := toolText(res)
require.Contains(t, body, `"total_steps":2`, "chain must record two applied steps")
require.Contains(t, body, `"applied_steps":2`)
require.Contains(t, body, "Helper", "step 1 must surface the added Helper symbol")
require.Contains(t, body, "target removed or renamed",
"step 2 must report the broken caller after Target is removed")
}
// TestSimulateChain_RejectsEmpty: an empty chain is an obvious caller
// error; the response must say so explicitly so the agent doesn't
// chase a phantom no-op result.
func TestSimulateChain_RejectsEmpty(t *testing.T) {
srv, _, _, _ := setupOverlayServer(t)
ctx := WithSessionID(context.Background(), "test")
res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
"steps": "[]",
})
require.True(t, res.IsError)
require.Contains(t, toolText(res), "steps array is empty")
}
// TestSimulateChain_RejectsNonArray: a JSON object passed where the
// schema demands an array must error with a helpful message rather
// than panic in the unmarshaller.
func TestSimulateChain_RejectsNonArray(t *testing.T) {
srv, _, _, _ := setupOverlayServer(t)
ctx := WithSessionID(context.Background(), "test")
res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
"steps": `{"changes":{}}`,
})
require.True(t, res.IsError)
require.Contains(t, toolText(res), "JSON array")
}
// TestSimulateChain_KeepPromotesOverlay verifies the `keep: true`
// promotion path: after a successful chain, the resulting state is
// pushed into a real overlay session bound to the caller's MCP
// session ID. A follow-up overlay_list must report the simulated file.
func TestSimulateChain_KeepPromotesOverlay(t *testing.T) {
srv, _, targetFile, _ := setupOverlayServer(t)
sessID := "sim-keep"
require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, ""))
ctx := WithSessionID(context.Background(), sessID)
step := buildSingleFileEdit(targetFile, "package main\n\nfunc Target() {}\n\nfunc Kept() {}\n")
steps := fmt.Sprintf(`[%s]`, step)
res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
"steps": steps,
"keep": true,
"diagnostics": false,
})
require.False(t, res.IsError, "simulate_chain failed: %s", toolText(res))
body := toolText(res)
require.Contains(t, body, `"kept":true`)
require.Contains(t, body, `"overlay_session_id":"`+sessID+`"`)
// And the overlay manager itself must now hold the file.
files, err := srv.OverlayManager().Files(sessID)
require.NoError(t, err)
require.NotEmpty(t, files, "keep must persist the simulated overlay")
// A subsequent get_file_summary on the touched file must reflect
// the promoted overlay state.
listRes := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{
"path": filepath.Base(targetFile),
})
require.False(t, listRes.IsError)
require.Contains(t, toolText(listRes), "Kept",
"after keep:true the overlay must surface the simulated symbol")
}
// TestSimulateChain_InheritOverlay: when a session already has an
// overlay pushed, simulate_chain with inherit_overlay:true must
// layer on top of that buffer state — not on top of base.
func TestSimulateChain_InheritOverlay(t *testing.T) {
srv, _, targetFile, _ := setupOverlayServer(t)
sessID := "sim-inherit"
require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, ""))
// Editor has an unsaved buffer that adds Helper().
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
Path: targetFile,
Content: "package main\n\nfunc Target() {}\n\nfunc Helper() {}\n",
}, nil))
ctx := WithSessionID(context.Background(), sessID)
// The simulation adds a third function on top of the inherited
// buffer; the resulting state must contain Helper AND Helper2.
step := buildSingleFileEdit(targetFile,
"package main\n\nfunc Target() {}\n\nfunc Helper() {}\n\nfunc Helper2() {}\n")
res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
"steps": fmt.Sprintf(`[%s]`, step),
"inherit_overlay": true,
"diagnostics": false,
})
require.False(t, res.IsError, "simulate_chain failed: %s", toolText(res))
body := toolText(res)
require.Contains(t, body, "Helper2", "added symbol must be visible")
require.Contains(t, body, `"inherit_overlay":true`)
}
// TestSimulateChain_StopsOnError: when stop_on_error is true (default)
// the chain must abort the moment a step introduces a new ERROR-level
// diagnostic delta. Without an LSP wired we can't drive this directly,
// so we exercise the parameter plumbing only — the diagnostics path
// is covered separately when an LSP is available.
func TestSimulateChain_StopsOnErrorFlag(t *testing.T) {
srv, _, targetFile, _ := setupOverlayServer(t)
ctx := WithSessionID(context.Background(), "sim-stop")
step := buildSingleFileEdit(targetFile, "package main\n\nfunc Target() {}\n")
res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
"steps": fmt.Sprintf(`[%s]`, step),
"stop_on_error": true,
"diagnostics": false,
})
require.False(t, res.IsError, "simulate_chain failed: %s", toolText(res))
body := toolText(res)
require.Contains(t, body, `"stopped_at":-1`,
"with no errors stopped_at must be -1")
}
// TestSimulateChain_KeepRequiresOverlayManager: the keep flag is a
// no-op (with kept:false) when the calling session has no MCP
// session ID — embedded stdio without ctx-attached session. Verify
// we don't drop a stack trace in that case.
func TestSimulateChain_KeepWithoutSessionDegrades(t *testing.T) {
srv, _, targetFile, _ := setupOverlayServer(t)
step := buildSingleFileEdit(targetFile, "package main\n\nfunc Target() {}\n")
ctx := context.Background() // no session ID
res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
"steps": fmt.Sprintf(`[%s]`, step),
"keep": true,
"diagnostics": false,
})
require.False(t, res.IsError, "simulate_chain must not error when no MCP session: %s", toolText(res))
require.Contains(t, toolText(res), `"kept":false`)
}
// TestPreviewEdit_CumulativeImpactReported confirms the response shape
// includes the impact rollup that the agent consumes to decide on
// test selection.
func TestPreviewEdit_CumulativeImpactReported(t *testing.T) {
srv, _, targetFile, _ := setupOverlayServer(t)
ctx := WithSessionID(context.Background(), "test")
res := callToolByName(t, srv, ctx, "preview_edit", map[string]any{
"workspace_edit": buildSingleFileEdit(targetFile, "package main\n\nfunc Renamed() {}\n"),
"diagnostics": false,
})
require.False(t, res.IsError, "preview_edit failed: %s", toolText(res))
body := toolText(res)
require.Contains(t, body, "impact", "impact rollup must be present")
require.Contains(t, body, "summary", "summary line must be present")
}
// TestSimulateChain_ConcurrentSessions: two MCP sessions running
// simulate_chain in parallel against the same workspace must not
// step on each other's overlay state, mirroring the OverlayManager
// multi-tenant guarantee.
func TestSimulateChain_ConcurrentSessions(t *testing.T) {
srv, _, targetFile, _ := setupOverlayServer(t)
run := func(t *testing.T, name string, contentTag string, expect string) {
t.Helper()
ctx := WithSessionID(context.Background(), name)
step := buildSingleFileEdit(targetFile, fmt.Sprintf("package main\n\nfunc Target() {}\n\nfunc %s() {}\n", contentTag))
res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
"steps": fmt.Sprintf(`[%s]`, step),
"diagnostics": false,
})
require.False(t, res.IsError, "simulate_chain failed: %s", toolText(res))
require.Contains(t, toolText(res), expect)
}
done := make(chan struct{}, 2)
go func() {
defer func() { done <- struct{}{} }()
run(t, "sim-a", "AlphaSym", "AlphaSym")
}()
go func() {
defer func() { done <- struct{}{} }()
run(t, "sim-b", "BetaSym", "BetaSym")
}()
for range 2 {
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatalf("simulation goroutines did not finish in time")
}
}
}
// TestSimulateChain_NewFile exercises the new-file path: a
// WorkspaceEdit whose target file doesn't exist on disk should
// produce an overlay that emits the file's contents as a fresh
// buffer. The simulator treats the (0,0) range with newText as the
// initial content for an absent file.
func TestSimulateChain_NewFile(t *testing.T) {
srv, dir, _, _ := setupOverlayServer(t)
newPath := filepath.Join(dir, "fresh.go")
step := buildSingleFileEdit(newPath, "package main\n\nfunc Fresh() {}\n")
ctx := WithSessionID(context.Background(), "sim-new")
res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
"steps": fmt.Sprintf(`[%s]`, step),
"diagnostics": false,
})
require.False(t, res.IsError, "simulate_chain failed: %s", toolText(res))
body := toolText(res)
require.True(t, strings.Contains(body, "Fresh") || strings.Contains(body, "fresh.go"),
"new-file simulation must surface the new file: %s", body)
}