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/fsnotify/fsnotify"
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 // Create a real fsnotify watcher for testing
64 watcher, err := fsnotify.NewWatcher()
65 if err != nil {
66 t.Fatalf("Failed to create fsnotify watcher: %v", err)
67 }
68 defer watcher.Close()
69
70 gw := &global{
71 watcher: watcher,
72 watchers: csync.NewMap[string, *Client](),
73 debounceTime: 300 * time.Millisecond,
74 debounceMap: csync.NewMap[string, *time.Timer](),
75 ctx: ctx,
76 cancel: cancel,
77 }
78
79 // Test that watching the same workspace multiple times is safe (idempotent)
80 err1 := gw.addDirectoryToWatcher(tempDir)
81 if err1 != nil {
82 t.Fatalf("First addDirectoryToWatcher call failed: %v", err1)
83 }
84
85 err2 := gw.addDirectoryToWatcher(tempDir)
86 if err2 != nil {
87 t.Fatalf("Second addDirectoryToWatcher call failed: %v", err2)
88 }
89
90 err3 := gw.addDirectoryToWatcher(tempDir)
91 if err3 != nil {
92 t.Fatalf("Third addDirectoryToWatcher call failed: %v", err3)
93 }
94
95 // All calls should succeed - fsnotify handles deduplication internally
96 // This test verifies that multiple WatchWorkspace calls are safe
97}
98
99func TestGlobalWatcherOnlyWatchesDirectories(t *testing.T) {
100 t.Parallel()
101
102 // Create a temporary directory structure for testing
103 tempDir := t.TempDir()
104 subDir := filepath.Join(tempDir, "subdir")
105 if err := os.Mkdir(subDir, 0o755); err != nil {
106 t.Fatalf("Failed to create subdirectory: %v", err)
107 }
108
109 // Create some files
110 file1 := filepath.Join(tempDir, "file1.txt")
111 file2 := filepath.Join(subDir, "file2.txt")
112 if err := os.WriteFile(file1, []byte("content1"), 0o644); err != nil {
113 t.Fatalf("Failed to create file1: %v", err)
114 }
115 if err := os.WriteFile(file2, []byte("content2"), 0o644); err != nil {
116 t.Fatalf("Failed to create file2: %v", err)
117 }
118
119 // Create a new global watcher instance for this test
120 ctx, cancel := context.WithCancel(context.Background())
121 defer cancel()
122
123 // Create a real fsnotify watcher for testing
124 watcher, err := fsnotify.NewWatcher()
125 if err != nil {
126 t.Fatalf("Failed to create fsnotify watcher: %v", err)
127 }
128 defer watcher.Close()
129
130 gw := &global{
131 watcher: watcher,
132 watchers: csync.NewMap[string, *Client](),
133 debounceTime: 300 * time.Millisecond,
134 debounceMap: csync.NewMap[string, *time.Timer](),
135 ctx: ctx,
136 cancel: cancel,
137 }
138
139 // Watch the workspace
140 err = gw.addDirectoryToWatcher(tempDir)
141 if err != nil {
142 t.Fatalf("addDirectoryToWatcher failed: %v", err)
143 }
144
145 // Verify that our expected directories exist and can be watched
146 expectedDirs := []string{tempDir, subDir}
147
148 for _, expectedDir := range expectedDirs {
149 info, err := os.Stat(expectedDir)
150 if err != nil {
151 t.Fatalf("Expected directory %s doesn't exist: %v", expectedDir, err)
152 }
153 if !info.IsDir() {
154 t.Fatalf("Expected %s to be a directory, but it's not", expectedDir)
155 }
156
157 // Try to add it again - fsnotify should handle this gracefully
158 err = gw.addDirectoryToWatcher(expectedDir)
159 if err != nil {
160 t.Fatalf("Failed to add directory %s to watcher: %v", expectedDir, err)
161 }
162 }
163
164 // Verify that files exist but we don't try to watch them directly
165 testFiles := []string{file1, file2}
166 for _, file := range testFiles {
167 info, err := os.Stat(file)
168 if err != nil {
169 t.Fatalf("Test file %s doesn't exist: %v", file, err)
170 }
171 if info.IsDir() {
172 t.Fatalf("Expected %s to be a file, but it's a directory", file)
173 }
174 }
175}
176
177func TestFsnotifyDeduplication(t *testing.T) {
178 t.Parallel()
179
180 // Create a temporary directory for testing
181 tempDir := t.TempDir()
182
183 // Create a real fsnotify watcher
184 watcher, err := fsnotify.NewWatcher()
185 if err != nil {
186 t.Fatalf("Failed to create fsnotify watcher: %v", err)
187 }
188 defer watcher.Close()
189
190 // Add the same directory multiple times
191 err1 := watcher.Add(tempDir)
192 if err1 != nil {
193 t.Fatalf("First Add failed: %v", err1)
194 }
195
196 err2 := watcher.Add(tempDir)
197 if err2 != nil {
198 t.Fatalf("Second Add failed: %v", err2)
199 }
200
201 err3 := watcher.Add(tempDir)
202 if err3 != nil {
203 t.Fatalf("Third Add failed: %v", err3)
204 }
205
206 // All should succeed - fsnotify handles deduplication internally
207 // This test verifies the fsnotify behavior we're relying on
208}
209
210func TestGlobalWatcherRespectsIgnoreFiles(t *testing.T) {
211 t.Parallel()
212
213 // Create a temporary directory structure for testing
214 tempDir := t.TempDir()
215
216 // Create directories that should be ignored
217 nodeModules := filepath.Join(tempDir, "node_modules")
218 target := filepath.Join(tempDir, "target")
219 customIgnored := filepath.Join(tempDir, "custom_ignored")
220 normalDir := filepath.Join(tempDir, "src")
221
222 for _, dir := range []string{nodeModules, target, customIgnored, normalDir} {
223 if err := os.MkdirAll(dir, 0o755); err != nil {
224 t.Fatalf("Failed to create directory %s: %v", dir, err)
225 }
226 }
227
228 // Create .gitignore file
229 gitignoreContent := "node_modules/\ntarget/\n"
230 if err := os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte(gitignoreContent), 0o644); err != nil {
231 t.Fatalf("Failed to create .gitignore: %v", err)
232 }
233
234 // Create .crushignore file
235 crushignoreContent := "custom_ignored/\n"
236 if err := os.WriteFile(filepath.Join(tempDir, ".crushignore"), []byte(crushignoreContent), 0o644); err != nil {
237 t.Fatalf("Failed to create .crushignore: %v", err)
238 }
239
240 // Create a new global watcher instance for this test
241 ctx, cancel := context.WithCancel(context.Background())
242 defer cancel()
243
244 // Create a real fsnotify watcher for testing
245 watcher, err := fsnotify.NewWatcher()
246 if err != nil {
247 t.Fatalf("Failed to create fsnotify watcher: %v", err)
248 }
249 defer watcher.Close()
250
251 gw := &global{
252 watcher: watcher,
253 watchers: csync.NewMap[string, *Client](),
254 debounceTime: 300 * time.Millisecond,
255 debounceMap: csync.NewMap[string, *time.Timer](),
256 ctx: ctx,
257 cancel: cancel,
258 }
259
260 // Watch the workspace
261 err = gw.addDirectoryToWatcher(tempDir)
262 if err != nil {
263 t.Fatalf("addDirectoryToWatcher failed: %v", err)
264 }
265
266 // This test verifies that the watcher can successfully add directories to fsnotify
267 // The actual ignore logic is tested in the fsext package
268 // Here we just verify that the watcher integration works
269}
270
271func TestGlobalWatcherShutdown(t *testing.T) {
272 t.Parallel()
273
274 // Create a new context for this test
275 ctx, cancel := context.WithCancel(context.Background())
276 defer cancel()
277
278 // Create a temporary global watcher for testing
279 gw := &global{
280 watchers: csync.NewMap[string, *Client](),
281 debounceTime: 300 * time.Millisecond,
282 debounceMap: csync.NewMap[string, *time.Timer](),
283 ctx: ctx,
284 cancel: cancel,
285 }
286
287 // Test shutdown doesn't panic
288 gw.shutdown()
289
290 // Verify context was cancelled
291 select {
292 case <-gw.ctx.Done():
293 // Expected
294 case <-time.After(100 * time.Millisecond):
295 t.Fatal("Expected context to be cancelled after shutdown")
296 }
297}