global_watcher_test.go

  1package watcher
  2
  3import (
  4	"context"
  5	"os"
  6	"path/filepath"
  7	"testing"
  8	"time"
  9
 10	"github.com/charmbracelet/crush/internal/csync"
 11	"github.com/raphamorim/notify"
 12)
 13
 14func TestGlobalWatcher(t *testing.T) {
 15	t.Parallel()
 16
 17	// Test that we can get the global watcher instance
 18	gw1 := instance()
 19	if gw1 == nil {
 20		t.Fatal("Expected global watcher instance, got nil")
 21	}
 22
 23	// Test that subsequent calls return the same instance (singleton)
 24	gw2 := instance()
 25	if gw1 != gw2 {
 26		t.Fatal("Expected same global watcher instance, got different instances")
 27	}
 28
 29	// Test registration and unregistration
 30	mockWatcher := &Client{
 31		name: "test-watcher",
 32	}
 33
 34	gw1.register("test", mockWatcher)
 35
 36	// Check that it was registered
 37	registered, _ := gw1.watchers.Get("test")
 38
 39	if registered != mockWatcher {
 40		t.Fatal("Expected workspace watcher to be registered")
 41	}
 42
 43	// Test unregistration
 44	gw1.unregister("test")
 45
 46	unregistered, _ := gw1.watchers.Get("test")
 47
 48	if unregistered != nil {
 49		t.Fatal("Expected workspace watcher to be unregistered")
 50	}
 51}
 52
 53func TestGlobalWatcherWorkspaceIdempotent(t *testing.T) {
 54	t.Parallel()
 55
 56	// Create a temporary directory for testing
 57	tempDir := t.TempDir()
 58
 59	// Create a new global watcher instance for this test
 60	ctx, cancel := context.WithCancel(context.Background())
 61	defer cancel()
 62
 63	gw := &global{
 64		events:       make(chan notify.EventInfo, 100),
 65		watchers:     csync.NewMap[string, *Client](),
 66		debounceTime: 300 * time.Millisecond,
 67		debounceMap:  csync.NewMap[string, *time.Timer](),
 68		ctx:          ctx,
 69		cancel:       cancel,
 70	}
 71
 72	// Test that watching the same workspace multiple times is safe (idempotent)
 73	// With notify, we use recursive watching with "..."
 74	watchPath := filepath.Join(tempDir, "...")
 75
 76	err1 := notify.Watch(watchPath, gw.events, notify.All)
 77	if err1 != nil {
 78		t.Fatalf("First Watch call failed: %v", err1)
 79	}
 80	defer notify.Stop(gw.events)
 81
 82	// Watching the same path again should be safe (notify handles this)
 83	err2 := notify.Watch(watchPath, gw.events, notify.All)
 84	if err2 != nil {
 85		t.Fatalf("Second Watch call failed: %v", err2)
 86	}
 87
 88	err3 := notify.Watch(watchPath, gw.events, notify.All)
 89	if err3 != nil {
 90		t.Fatalf("Third Watch call failed: %v", err3)
 91	}
 92
 93	// All calls should succeed - notify handles deduplication internally
 94	// This test verifies that multiple Watch calls are safe
 95}
 96
 97func TestGlobalWatcherRecursiveWatching(t *testing.T) {
 98	t.Parallel()
 99
100	// Create a temporary directory structure for testing
101	tempDir := t.TempDir()
102	subDir := filepath.Join(tempDir, "subdir")
103	if err := os.Mkdir(subDir, 0o755); err != nil {
104		t.Fatalf("Failed to create subdirectory: %v", err)
105	}
106
107	// Create some files
108	file1 := filepath.Join(tempDir, "file1.txt")
109	file2 := filepath.Join(subDir, "file2.txt")
110	if err := os.WriteFile(file1, []byte("content1"), 0o644); err != nil {
111		t.Fatalf("Failed to create file1: %v", err)
112	}
113	if err := os.WriteFile(file2, []byte("content2"), 0o644); err != nil {
114		t.Fatalf("Failed to create file2: %v", err)
115	}
116
117	// Create a new global watcher instance for this test
118	ctx, cancel := context.WithCancel(context.Background())
119	defer cancel()
120
121	gw := &global{
122		events:       make(chan notify.EventInfo, 100),
123		watchers:     csync.NewMap[string, *Client](),
124		debounceTime: 300 * time.Millisecond,
125		debounceMap:  csync.NewMap[string, *time.Timer](),
126		ctx:          ctx,
127		cancel:       cancel,
128		root:         tempDir,
129	}
130
131	// Set up recursive watching on the root directory
132	watchPath := filepath.Join(tempDir, "...")
133	if err := notify.Watch(watchPath, gw.events, notify.All); err != nil {
134		t.Fatalf("Failed to set up recursive watch: %v", err)
135	}
136	defer notify.Stop(gw.events)
137
138	// Verify that our expected directories and files exist
139	expectedDirs := []string{tempDir, subDir}
140
141	for _, expectedDir := range expectedDirs {
142		info, err := os.Stat(expectedDir)
143		if err != nil {
144			t.Fatalf("Expected directory %s doesn't exist: %v", expectedDir, err)
145		}
146		if !info.IsDir() {
147			t.Fatalf("Expected %s to be a directory, but it's not", expectedDir)
148		}
149	}
150
151	// Verify that files exist
152	testFiles := []string{file1, file2}
153	for _, file := range testFiles {
154		info, err := os.Stat(file)
155		if err != nil {
156			t.Fatalf("Test file %s doesn't exist: %v", file, err)
157		}
158		if info.IsDir() {
159			t.Fatalf("Expected %s to be a file, but it's a directory", file)
160		}
161	}
162
163	// Create a new file in the subdirectory to test recursive watching
164	newFile := filepath.Join(subDir, "new.txt")
165	if err := os.WriteFile(newFile, []byte("new content"), 0o644); err != nil {
166		t.Fatalf("Failed to create new file: %v", err)
167	}
168
169	// We should receive an event for the file creation
170	select {
171	case event := <-gw.events:
172		// On macOS, paths might have /private prefix, so we need to compare the real paths
173		eventPath, _ := filepath.EvalSymlinks(event.Path())
174		expectedPath, _ := filepath.EvalSymlinks(newFile)
175		if eventPath != expectedPath {
176			// Also try comparing just the base names as a fallback
177			if filepath.Base(event.Path()) != filepath.Base(newFile) {
178				t.Errorf("Expected event for %s, got %s", newFile, event.Path())
179			}
180		}
181	case <-time.After(2 * time.Second):
182		t.Fatal("Timeout waiting for file creation event")
183	}
184}
185
186func TestNotifyDeduplication(t *testing.T) {
187	t.Parallel()
188
189	// Create a temporary directory for testing
190	tempDir := t.TempDir()
191
192	// Create an event channel
193	events := make(chan notify.EventInfo, 100)
194	defer close(events)
195
196	// Add the same directory multiple times with recursive watching
197	watchPath := filepath.Join(tempDir, "...")
198
199	err1 := notify.Watch(watchPath, events, notify.All)
200	if err1 != nil {
201		t.Fatalf("First Watch failed: %v", err1)
202	}
203	defer notify.Stop(events)
204
205	err2 := notify.Watch(watchPath, events, notify.All)
206	if err2 != nil {
207		t.Fatalf("Second Watch failed: %v", err2)
208	}
209
210	err3 := notify.Watch(watchPath, events, notify.All)
211	if err3 != nil {
212		t.Fatalf("Third Watch failed: %v", err3)
213	}
214
215	// All should succeed - notify handles deduplication internally
216	// This test verifies the notify behavior we're relying on
217}
218
219func TestGlobalWatcherRespectsIgnoreFiles(t *testing.T) {
220	t.Parallel()
221
222	// Create a temporary directory structure for testing
223	tempDir := t.TempDir()
224
225	// Create directories that should be ignored
226	nodeModules := filepath.Join(tempDir, "node_modules")
227	target := filepath.Join(tempDir, "target")
228	customIgnored := filepath.Join(tempDir, "custom_ignored")
229	normalDir := filepath.Join(tempDir, "src")
230
231	for _, dir := range []string{nodeModules, target, customIgnored, normalDir} {
232		if err := os.MkdirAll(dir, 0o755); err != nil {
233			t.Fatalf("Failed to create directory %s: %v", dir, err)
234		}
235	}
236
237	// Create .gitignore file
238	gitignoreContent := "node_modules/\ntarget/\n"
239	if err := os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte(gitignoreContent), 0o644); err != nil {
240		t.Fatalf("Failed to create .gitignore: %v", err)
241	}
242
243	// Create .crushignore file
244	crushignoreContent := "custom_ignored/\n"
245	if err := os.WriteFile(filepath.Join(tempDir, ".crushignore"), []byte(crushignoreContent), 0o644); err != nil {
246		t.Fatalf("Failed to create .crushignore: %v", err)
247	}
248
249	// Create a new global watcher instance for this test
250	ctx, cancel := context.WithCancel(context.Background())
251	defer cancel()
252
253	gw := &global{
254		events:       make(chan notify.EventInfo, 100),
255		watchers:     csync.NewMap[string, *Client](),
256		debounceTime: 300 * time.Millisecond,
257		debounceMap:  csync.NewMap[string, *time.Timer](),
258		ctx:          ctx,
259		cancel:       cancel,
260		root:         tempDir,
261	}
262
263	// Set up recursive watching
264	watchPath := filepath.Join(tempDir, "...")
265	if err := notify.Watch(watchPath, gw.events, notify.All); err != nil {
266		t.Fatalf("Failed to set up recursive watch: %v", err)
267	}
268	defer notify.Stop(gw.events)
269
270	// The notify library watches everything, but our processEvents
271	// function should filter out ignored files using fsext.ShouldExcludeFile
272	// This test verifies that the structure is set up correctly
273}
274
275func TestGlobalWatcherShutdown(t *testing.T) {
276	t.Parallel()
277
278	// Create a new context for this test
279	ctx, cancel := context.WithCancel(context.Background())
280	defer cancel()
281
282	// Create a temporary global watcher for testing
283	gw := &global{
284		events:       make(chan notify.EventInfo, 100),
285		watchers:     csync.NewMap[string, *Client](),
286		debounceTime: 300 * time.Millisecond,
287		debounceMap:  csync.NewMap[string, *time.Timer](),
288		ctx:          ctx,
289		cancel:       cancel,
290	}
291
292	// Test shutdown doesn't panic
293	gw.shutdown()
294
295	// Verify context was cancelled
296	select {
297	case <-gw.ctx.Done():
298		// Expected
299	case <-time.After(100 * time.Millisecond):
300		t.Fatal("Expected context to be cancelled after shutdown")
301	}
302}