client_test.go

  1package lsp
  2
  3import (
  4	"context"
  5	"fmt"
  6	"testing"
  7	"time"
  8
  9	"github.com/charmbracelet/crush/internal/config"
 10	"github.com/charmbracelet/crush/internal/csync"
 11	"github.com/charmbracelet/crush/internal/env"
 12	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
 13	"github.com/stretchr/testify/require"
 14)
 15
 16func TestClient(t *testing.T) {
 17	ctx := context.Background()
 18
 19	// Create a simple config for testing
 20	cfg := config.LSPConfig{
 21		Command:   "$THE_CMD", // Use echo as a dummy command that won't fail
 22		Args:      []string{"hello"},
 23		FileTypes: []string{"go"},
 24		Env:       map[string]string{},
 25	}
 26
 27	// Test creating a powernap client - this will likely fail with echo
 28	// but we can still test the basic structure
 29	client, err := New(ctx, "test", cfg, config.NewShellVariableResolver(env.NewFromMap(map[string]string{
 30		"THE_CMD": "echo",
 31	})), ".", false)
 32	if err != nil {
 33		// Expected to fail with echo command, skip the rest
 34		t.Skipf("Powernap client creation failed as expected with dummy command: %v", err)
 35		return
 36	}
 37
 38	// If we get here, test basic interface methods
 39	if client.GetName() != "test" {
 40		t.Errorf("Expected name 'test', got '%s'", client.GetName())
 41	}
 42
 43	if !client.HandlesFile("test.go") {
 44		t.Error("Expected client to handle .go files")
 45	}
 46
 47	if client.HandlesFile("test.py") {
 48		t.Error("Expected client to not handle .py files")
 49	}
 50
 51	// Test server state
 52	client.SetServerState(StateReady)
 53	if client.GetServerState() != StateReady {
 54		t.Error("Expected server state to be StateReady")
 55	}
 56
 57	// Clean up - expect this to fail with echo command
 58	if err := client.Close(t.Context()); err != nil {
 59		// Expected to fail with echo command
 60		t.Logf("Close failed as expected with dummy command: %v", err)
 61	}
 62}
 63
 64// TestNew_ExpansionFailure_Args pins that a failing $(cmd) in LSP
 65// args surfaces as a load error prefixed "invalid lsp args:" and that
 66// no client is returned. Mirrors the MCP contract where expansion
 67// failure hard-stops transport creation rather than silently running
 68// with an empty or literal value.
 69func TestNew_ExpansionFailure_Args(t *testing.T) {
 70	t.Parallel()
 71
 72	cfg := config.LSPConfig{
 73		Command: "echo",
 74		Args:    []string{"--root", "$(false)"},
 75	}
 76	resolver := config.NewShellVariableResolver(env.NewFromMap(map[string]string{}))
 77
 78	client, err := New(t.Context(), "test-args-fail", cfg, resolver, ".", false)
 79	require.Error(t, err)
 80	require.Nil(t, client, "client must not start when args expansion fails")
 81	require.Contains(t, err.Error(), "invalid lsp args")
 82}
 83
 84// TestNew_ExpansionFailure_Env pins the same contract for env values.
 85func TestNew_ExpansionFailure_Env(t *testing.T) {
 86	t.Parallel()
 87
 88	cfg := config.LSPConfig{
 89		Command: "echo",
 90		Env:     map[string]string{"BAD": "$(false)"},
 91	}
 92	resolver := config.NewShellVariableResolver(env.NewFromMap(map[string]string{}))
 93
 94	client, err := New(t.Context(), "test-env-fail", cfg, resolver, ".", false)
 95	require.Error(t, err)
 96	require.Nil(t, client, "client must not start when env expansion fails")
 97	require.Contains(t, err.Error(), "invalid lsp env")
 98}
 99
100func TestNilClient(t *testing.T) {
101	t.Parallel()
102
103	var c *Client
104
105	require.False(t, c.HandlesFile("/some/file.go"))
106	require.Equal(t, DiagnosticCounts{}, c.GetDiagnosticCounts())
107	require.Nil(t, c.GetDiagnostics())
108	require.Nil(t, c.OpenFileOnDemand(context.Background(), "/some/file.go"))
109	require.Nil(t, c.NotifyChange(context.Background(), "/some/file.go"))
110	c.WaitForDiagnostics(context.Background(), time.Second)
111}
112
113func newTestClient() *Client {
114	c := &Client{
115		name:        "test",
116		diagnostics: csync.NewVersionedMap[protocol.DocumentURI, []protocol.Diagnostic](),
117		openFiles:   csync.NewMap[string, *OpenFileInfo](),
118	}
119	c.serverState.Store(StateStopped)
120	return c
121}
122
123func TestWaitForDiagnostics_NoChange(t *testing.T) {
124	t.Parallel()
125
126	c := newTestClient()
127	start := time.Now()
128	c.WaitForDiagnostics(t.Context(), 5*time.Second)
129	elapsed := time.Since(start)
130
131	// Should return early via firstChangeDeadline (~1s), not the full timeout.
132	require.Less(t, elapsed, 2*time.Second, "should return early when no diagnostics change")
133}
134
135func TestWaitForDiagnostics_ImmediateChange(t *testing.T) {
136	t.Parallel()
137
138	c := newTestClient()
139
140	go func() {
141		time.Sleep(100 * time.Millisecond)
142		c.diagnostics.Set(protocol.DocumentURI("file:///test.go"), nil)
143	}()
144
145	start := time.Now()
146	c.WaitForDiagnostics(t.Context(), 5*time.Second)
147	elapsed := time.Since(start)
148
149	// Should detect the change and then settle (~300ms settle + overhead).
150	require.Less(t, elapsed, 2*time.Second, "should return after settling, not full timeout")
151	require.Greater(t, elapsed, 200*time.Millisecond, "should wait for settle duration")
152}
153
154func TestWaitForDiagnostics_RepeatedChanges(t *testing.T) {
155	t.Parallel()
156
157	c := newTestClient()
158
159	// Simulate an LSP server that publishes diagnostics in bursts.
160	go func() {
161		for i := range 5 {
162			time.Sleep(50 * time.Millisecond)
163			c.diagnostics.Set(protocol.DocumentURI("file:///test.go"), []protocol.Diagnostic{
164				{Message: fmt.Sprintf("diag-%d", i)},
165			})
166		}
167	}()
168
169	start := time.Now()
170	c.WaitForDiagnostics(t.Context(), 5*time.Second)
171	elapsed := time.Since(start)
172
173	// Should wait for diagnostics to settle after the burst finishes.
174	// Burst lasts ~250ms, then 300ms settle window, so total ~550ms+.
175	require.Less(t, elapsed, 2*time.Second, "should return after settling, not full timeout")
176	require.Greater(t, elapsed, 400*time.Millisecond, "should wait for all changes to settle")
177}
178
179func TestWaitForDiagnostics_ContextCancellation(t *testing.T) {
180	t.Parallel()
181
182	c := newTestClient()
183	ctx, cancel := context.WithCancel(t.Context())
184	defer cancel()
185
186	go func() {
187		time.Sleep(200 * time.Millisecond)
188		cancel()
189	}()
190
191	start := time.Now()
192	c.WaitForDiagnostics(ctx, 5*time.Second)
193	elapsed := time.Since(start)
194
195	require.Less(t, elapsed, 1*time.Second, "should return shortly after context cancellation")
196}
197
198func TestWaitForDiagnostics_NilClient(t *testing.T) {
199	t.Parallel()
200
201	var c *Client
202	// Should not panic.
203	c.WaitForDiagnostics(context.Background(), time.Second)
204}