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}