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.NewEnvironmentVariableResolver(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
 64func TestNilClient(t *testing.T) {
 65	t.Parallel()
 66
 67	var c *Client
 68
 69	require.False(t, c.HandlesFile("/some/file.go"))
 70	require.Equal(t, DiagnosticCounts{}, c.GetDiagnosticCounts())
 71	require.Nil(t, c.GetDiagnostics())
 72	require.Nil(t, c.OpenFileOnDemand(context.Background(), "/some/file.go"))
 73	require.Nil(t, c.NotifyChange(context.Background(), "/some/file.go"))
 74	c.WaitForDiagnostics(context.Background(), time.Second)
 75}
 76
 77func newTestClient() *Client {
 78	c := &Client{
 79		name:        "test",
 80		diagnostics: csync.NewVersionedMap[protocol.DocumentURI, []protocol.Diagnostic](),
 81		openFiles:   csync.NewMap[string, *OpenFileInfo](),
 82	}
 83	c.serverState.Store(StateStopped)
 84	return c
 85}
 86
 87func TestWaitForDiagnostics_NoChange(t *testing.T) {
 88	t.Parallel()
 89
 90	c := newTestClient()
 91	start := time.Now()
 92	c.WaitForDiagnostics(t.Context(), 5*time.Second)
 93	elapsed := time.Since(start)
 94
 95	// Should return early via firstChangeDeadline (~1s), not the full timeout.
 96	require.Less(t, elapsed, 2*time.Second, "should return early when no diagnostics change")
 97}
 98
 99func TestWaitForDiagnostics_ImmediateChange(t *testing.T) {
100	t.Parallel()
101
102	c := newTestClient()
103
104	go func() {
105		time.Sleep(100 * time.Millisecond)
106		c.diagnostics.Set(protocol.DocumentURI("file:///test.go"), nil)
107	}()
108
109	start := time.Now()
110	c.WaitForDiagnostics(t.Context(), 5*time.Second)
111	elapsed := time.Since(start)
112
113	// Should detect the change and then settle (~300ms settle + overhead).
114	require.Less(t, elapsed, 2*time.Second, "should return after settling, not full timeout")
115	require.Greater(t, elapsed, 200*time.Millisecond, "should wait for settle duration")
116}
117
118func TestWaitForDiagnostics_RepeatedChanges(t *testing.T) {
119	t.Parallel()
120
121	c := newTestClient()
122
123	// Simulate an LSP server that publishes diagnostics in bursts.
124	go func() {
125		for i := range 5 {
126			time.Sleep(50 * time.Millisecond)
127			c.diagnostics.Set(protocol.DocumentURI("file:///test.go"), []protocol.Diagnostic{
128				{Message: fmt.Sprintf("diag-%d", i)},
129			})
130		}
131	}()
132
133	start := time.Now()
134	c.WaitForDiagnostics(t.Context(), 5*time.Second)
135	elapsed := time.Since(start)
136
137	// Should wait for diagnostics to settle after the burst finishes.
138	// Burst lasts ~250ms, then 300ms settle window, so total ~550ms+.
139	require.Less(t, elapsed, 2*time.Second, "should return after settling, not full timeout")
140	require.Greater(t, elapsed, 400*time.Millisecond, "should wait for all changes to settle")
141}
142
143func TestWaitForDiagnostics_ContextCancellation(t *testing.T) {
144	t.Parallel()
145
146	c := newTestClient()
147	ctx, cancel := context.WithCancel(t.Context())
148	defer cancel()
149
150	go func() {
151		time.Sleep(200 * time.Millisecond)
152		cancel()
153	}()
154
155	start := time.Now()
156	c.WaitForDiagnostics(ctx, 5*time.Second)
157	elapsed := time.Since(start)
158
159	require.Less(t, elapsed, 1*time.Second, "should return shortly after context cancellation")
160}
161
162func TestWaitForDiagnostics_NilClient(t *testing.T) {
163	t.Parallel()
164
165	var c *Client
166	// Should not panic.
167	c.WaitForDiagnostics(context.Background(), time.Second)
168}