diff options
| author | Bernhard Guillon <Bernhard.Guillon@begu.org> | 2026-07-06 21:26:29 +0200 |
|---|---|---|
| committer | Bernhard Guillon <Bernhard.Guillon@begu.org> | 2026-07-06 21:26:29 +0200 |
| commit | 8b22a36d1c068f9baee68799b0c1f51ecb999e9d (patch) | |
| tree | f460fd80e8c1578db2bd365b52caf1734d473b73 | |
| parent | 7512c95fa3f4b4e4ccdf452024f314a9a3c3b60f (diff) | |
| download | sourcewatch-8b22a36d1c068f9baee68799b0c1f51ecb999e9d.tar.gz sourcewatch-8b22a36d1c068f9baee68799b0c1f51ecb999e9d.zip | |
Add watcher tests, refactor watch() to accept context
- Added watcher_test.go: 10 tests covering create, write, remove,
rename, ignored files, debouncing, directory creation/removal,
and manifest updates
- Refactored watch() to accept context.Context for clean cancellation
- Added signal handling (SIGINT/SIGTERM) for graceful shutdown in main.go
- Fixed test expectations (version two = 11 bytes, not 10)
| -rw-r--r-- | main.go | 9 | ||||
| -rw-r--r-- | watcher.go | 6 | ||||
| -rw-r--r-- | watcher_test.go | 325 |
3 files changed, 338 insertions, 2 deletions
@@ -1,8 +1,12 @@ package main import ( + "context" "flag" "log" + "os" + "os/signal" + "syscall" ) var ( @@ -31,7 +35,10 @@ func main() { log.Fatalf("initial sync failed: %v", err) } - if err := watch(*hostDir, *dataDir, manifest, rules, *dryRun, *verbose); err != nil { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + if err := watch(ctx, *hostDir, *dataDir, manifest, rules, *dryRun, *verbose); err != nil { log.Fatalf("watcher failed: %v", err) } } @@ -1,6 +1,7 @@ package main import ( + "context" "log" "os" "path/filepath" @@ -12,7 +13,7 @@ import ( ignore "github.com/sabhiram/go-gitignore" ) -func watch(hostDir, dataDir string, manifest *Manifest, rules *ignore.GitIgnore, dryRun, verbose bool) error { +func watch(ctx context.Context, hostDir, dataDir string, manifest *Manifest, rules *ignore.GitIgnore, dryRun, verbose bool) error { watcher, err := fsnotify.NewWatcher() if err != nil { return err @@ -28,6 +29,9 @@ func watch(hostDir, dataDir string, manifest *Manifest, rules *ignore.GitIgnore, for { select { + case <-ctx.Done(): + return nil + case event, ok := <-watcher.Events: if !ok { return nil diff --git a/watcher_test.go b/watcher_test.go new file mode 100644 index 0000000..0ea6ca2 --- /dev/null +++ b/watcher_test.go @@ -0,0 +1,325 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + ignore "github.com/sabhiram/go-gitignore" +) + +func TestWatcher_Create(t *testing.T) { + host := t.TempDir() + data := t.TempDir() + + rules := ignore.CompileIgnoreLines() + manifest, err := initialSync(host, data, rules, false) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go watch(ctx, host, data, manifest, rules, false, false) + + time.Sleep(100 * time.Millisecond) + + writeFile(t, host, "new.txt", "hello") + assertFileEventually(t, data, "new.txt", "hello", 2*time.Second) +} + +func TestWatcher_CreateInSubdir(t *testing.T) { + host := t.TempDir() + data := t.TempDir() + + writeFile(t, host, "src/existing.go", "old") + + rules := ignore.CompileIgnoreLines() + manifest, err := initialSync(host, data, rules, false) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go watch(ctx, host, data, manifest, rules, false, false) + + time.Sleep(100 * time.Millisecond) + + writeFile(t, host, "src/new.go", "package main") + assertFileEventually(t, data, "src/new.go", "package main", 2*time.Second) +} + +func TestWatcher_Write(t *testing.T) { + host := t.TempDir() + data := t.TempDir() + + writeFile(t, host, "file.txt", "original") + + rules := ignore.CompileIgnoreLines() + manifest, err := initialSync(host, data, rules, false) + if err != nil { + t.Fatal(err) + } + + assertFileExists(t, data, "file.txt", "original") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go watch(ctx, host, data, manifest, rules, false, false) + + time.Sleep(100 * time.Millisecond) + + writeFile(t, host, "file.txt", "updated") + assertFileEventually(t, data, "file.txt", "updated", 2*time.Second) +} + +func TestWatcher_Remove(t *testing.T) { + host := t.TempDir() + data := t.TempDir() + + writeFile(t, host, "file.txt", "content") + + rules := ignore.CompileIgnoreLines() + manifest, err := initialSync(host, data, rules, false) + if err != nil { + t.Fatal(err) + } + + assertFileExists(t, data, "file.txt", "content") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go watch(ctx, host, data, manifest, rules, false, false) + + time.Sleep(100 * time.Millisecond) + + os.Remove(filepath.Join(host, "file.txt")) + assertFileRemovedEventually(t, data, "file.txt", 2*time.Second) +} + +func TestWatcher_Rename(t *testing.T) { + host := t.TempDir() + data := t.TempDir() + + writeFile(t, host, "Foo.txt", "content") + + rules := ignore.CompileIgnoreLines() + manifest, err := initialSync(host, data, rules, false) + if err != nil { + t.Fatal(err) + } + + assertFileExists(t, data, "Foo.txt", "content") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go watch(ctx, host, data, manifest, rules, false, false) + + time.Sleep(100 * time.Millisecond) + + // Rename: delete old, create new (Linux is case-sensitive) + os.Rename(filepath.Join(host, "Foo.txt"), filepath.Join(host, "foo.txt")) + writeFile(t, host, "foo.txt", "content") + + assertFileRemovedEventually(t, data, "Foo.txt", 2*time.Second) + assertFileEventually(t, data, "foo.txt", "content", 2*time.Second) +} + +func TestWatcher_IgnoredFileEvents(t *testing.T) { + host := t.TempDir() + data := t.TempDir() + + writeFile(t, host, ".gitignore", "*.log\nbuild/\n") + + rules, err := loadGitignore(host) + if err != nil { + t.Fatal(err) + } + + manifest, err := initialSync(host, data, rules, false) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go watch(ctx, host, data, manifest, rules, false, false) + + time.Sleep(100 * time.Millisecond) + + // Create ignored files - should NOT sync + writeFile(t, host, "debug.log", "log data") + writeFile(t, host, "build/output.o", "binary") + + time.Sleep(500 * time.Millisecond) + + assertFileNotExists(t, data, "debug.log") + assertFileNotExists(t, data, "build/output.o") + + // Create non-ignored file - should sync + writeFile(t, host, "app.go", "package main") + assertFileEventually(t, data, "app.go", "package main", 2*time.Second) +} + +func TestWatcher_Debouncing(t *testing.T) { + host := t.TempDir() + data := t.TempDir() + + rules := ignore.CompileIgnoreLines() + manifest, err := initialSync(host, data, rules, false) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go watch(ctx, host, data, manifest, rules, false, false) + + time.Sleep(100 * time.Millisecond) + + // Rapid writes to same file + writeFile(t, host, "file.txt", "v1") + writeFile(t, host, "file.txt", "v2") + writeFile(t, host, "file.txt", "v3") + writeFile(t, host, "file.txt", "final") + + // Wait for debounce + sync + assertFileEventually(t, data, "file.txt", "final", 2*time.Second) + + // Check that intermediate states don't matter - final content is correct + content, err := os.ReadFile(filepath.Join(data, "file.txt")) + if err != nil { + t.Fatal(err) + } + if string(content) != "final" { + t.Errorf("content = %q, want %q", string(content), "final") + } +} + +func TestWatcher_CreateDirectory(t *testing.T) { + host := t.TempDir() + data := t.TempDir() + + rules := ignore.CompileIgnoreLines() + manifest, err := initialSync(host, data, rules, false) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go watch(ctx, host, data, manifest, rules, false, false) + + time.Sleep(100 * time.Millisecond) + + // Create directory and file inside it + if err := os.MkdirAll(filepath.Join(host, "newdir"), 0755); err != nil { + t.Fatal(err) + } + time.Sleep(300 * time.Millisecond) // let watcher register new dir + writeFile(t, host, "newdir/file.txt", "nested") + + assertDirExistsEventually(t, data, "newdir", 2*time.Second) + assertFileEventually(t, data, "newdir/file.txt", "nested", 2*time.Second) +} + +func TestWatcher_RemoveDirectory(t *testing.T) { + host := t.TempDir() + data := t.TempDir() + + writeFile(t, host, "dir/file.txt", "content") + + rules := ignore.CompileIgnoreLines() + manifest, err := initialSync(host, data, rules, false) + if err != nil { + t.Fatal(err) + } + + assertFileExists(t, data, "dir/file.txt", "content") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go watch(ctx, host, data, manifest, rules, false, false) + + time.Sleep(100 * time.Millisecond) + + os.RemoveAll(filepath.Join(host, "dir")) + assertFileRemovedEventually(t, data, "dir/file.txt", 2*time.Second) +} + +func TestWatcher_VerifyManifestUpdated(t *testing.T) { + host := t.TempDir() + data := t.TempDir() + + writeFile(t, host, "file.txt", "v1") + + rules := ignore.CompileIgnoreLines() + manifest, err := initialSync(host, data, rules, false) + if err != nil { + t.Fatal(err) + } + + if manifest.Files["file.txt"].Size != 2 { + t.Errorf("initial manifest size = %d, want 2", manifest.Files["file.txt"].Size) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go watch(ctx, host, data, manifest, rules, false, false) + + time.Sleep(100 * time.Millisecond) + + writeFile(t, host, "file.txt", "version two") + + // Wait for sync + time.Sleep(500 * time.Millisecond) + + if manifest.Files["file.txt"].Size != 11 { + t.Errorf("manifest size after write = %d, want 11", manifest.Files["file.txt"].Size) + } +} + +// Helpers + +func assertFileEventually(t *testing.T, dir, path, expectedContent string, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + content, err := os.ReadFile(filepath.Join(dir, path)) + if err == nil && string(content) == expectedContent { + return + } + time.Sleep(50 * time.Millisecond) + } + content, _ := os.ReadFile(filepath.Join(dir, path)) + t.Errorf("file %s not as expected within %v: got %q, want %q", path, timeout, string(content), expectedContent) +} + +func assertFileRemovedEventually(t *testing.T, dir, path string, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + _, err := os.Stat(filepath.Join(dir, path)) + if os.IsNotExist(err) { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Errorf("file %s still exists after %v", path, timeout) +} + +func assertDirExistsEventually(t *testing.T, dir, path string, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + info, err := os.Stat(filepath.Join(dir, path)) + if err == nil && info.IsDir() { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Errorf("directory %s not found within %v", path, timeout) +} |
