esync

Directory watching and remote syncing
Log | Files | Refs | README | LICENSE

watcher_test.go (6379B)


      1 package watcher
      2 
      3 import (
      4 	"os"
      5 	"path/filepath"
      6 	"sync/atomic"
      7 	"testing"
      8 	"time"
      9 )
     10 
     11 // ---------------------------------------------------------------------------
     12 // 1. TestDebouncerBatchesEvents — rapid events produce exactly one callback
     13 // ---------------------------------------------------------------------------
     14 func TestDebouncerBatchesEvents(t *testing.T) {
     15 	var count atomic.Int64
     16 
     17 	d := NewDebouncer(100*time.Millisecond, func() {
     18 		count.Add(1)
     19 	})
     20 	defer d.Stop()
     21 
     22 	// Fire 5 events rapidly, 10ms apart
     23 	for i := 0; i < 5; i++ {
     24 		d.Trigger()
     25 		time.Sleep(10 * time.Millisecond)
     26 	}
     27 
     28 	// Wait for debounce window to expire plus margin
     29 	time.Sleep(200 * time.Millisecond)
     30 
     31 	got := count.Load()
     32 	if got != 1 {
     33 		t.Errorf("callback fired %d times, want 1", got)
     34 	}
     35 }
     36 
     37 // ---------------------------------------------------------------------------
     38 // 2. TestDebouncerSeparateEvents — two events separated by more than the
     39 //    debounce interval should fire the callback twice
     40 // ---------------------------------------------------------------------------
     41 func TestDebouncerSeparateEvents(t *testing.T) {
     42 	var count atomic.Int64
     43 
     44 	d := NewDebouncer(50*time.Millisecond, func() {
     45 		count.Add(1)
     46 	})
     47 	defer d.Stop()
     48 
     49 	// First event
     50 	d.Trigger()
     51 	// Wait for the debounce to fire
     52 	time.Sleep(150 * time.Millisecond)
     53 
     54 	// Second event
     55 	d.Trigger()
     56 	// Wait for the debounce to fire
     57 	time.Sleep(150 * time.Millisecond)
     58 
     59 	got := count.Load()
     60 	if got != 2 {
     61 		t.Errorf("callback fired %d times, want 2", got)
     62 	}
     63 }
     64 
     65 // ---------------------------------------------------------------------------
     66 // 3. TestDebouncerStopCancelsPending — Stop prevents a pending callback
     67 // ---------------------------------------------------------------------------
     68 func TestDebouncerStopCancelsPending(t *testing.T) {
     69 	var count atomic.Int64
     70 
     71 	d := NewDebouncer(100*time.Millisecond, func() {
     72 		count.Add(1)
     73 	})
     74 
     75 	d.Trigger()
     76 	// Stop before the debounce interval elapses
     77 	time.Sleep(20 * time.Millisecond)
     78 	d.Stop()
     79 
     80 	// Wait past the debounce interval
     81 	time.Sleep(200 * time.Millisecond)
     82 
     83 	got := count.Load()
     84 	if got != 0 {
     85 		t.Errorf("callback fired %d times after Stop, want 0", got)
     86 	}
     87 }
     88 
     89 // ---------------------------------------------------------------------------
     90 // 4. TestShouldIgnore — verify ignore pattern matching
     91 // ---------------------------------------------------------------------------
     92 func TestShouldIgnore(t *testing.T) {
     93 	w := &Watcher{
     94 		ignores: []string{".git", "*.tmp", "node_modules"},
     95 	}
     96 
     97 	tests := []struct {
     98 		path   string
     99 		expect bool
    100 	}{
    101 		{"/project/.git", true},
    102 		{"/project/foo.tmp", true},
    103 		{"/project/node_modules", true},
    104 		{"/project/main.go", false},
    105 		{"/project/src/app.go", false},
    106 	}
    107 
    108 	for _, tt := range tests {
    109 		got := w.shouldIgnore(tt.path)
    110 		if got != tt.expect {
    111 			t.Errorf("shouldIgnore(%q) = %v, want %v", tt.path, got, tt.expect)
    112 		}
    113 	}
    114 }
    115 
    116 // ---------------------------------------------------------------------------
    117 // 5. TestShouldInclude — verify include prefix matching
    118 // ---------------------------------------------------------------------------
    119 func TestShouldInclude(t *testing.T) {
    120 	w := &Watcher{
    121 		rootPath: "/project",
    122 		includes: []string{"src", "docs/api"},
    123 	}
    124 
    125 	tests := []struct {
    126 		path   string
    127 		expect bool
    128 	}{
    129 		{"/project/src/main.go", true},
    130 		{"/project/src/pkg/util.go", true},
    131 		{"/project/docs/api/readme.md", true},
    132 		{"/project/docs", true},           // ancestor of docs/api
    133 		{"/project/tmp/cache.bin", false},
    134 		{"/project/build/out.o", false},
    135 		{"/project", true},                // root always included
    136 	}
    137 
    138 	for _, tt := range tests {
    139 		got := w.shouldInclude(tt.path)
    140 		if got != tt.expect {
    141 			t.Errorf("shouldInclude(%q) = %v, want %v", tt.path, got, tt.expect)
    142 		}
    143 	}
    144 }
    145 
    146 // ---------------------------------------------------------------------------
    147 // 6. TestShouldIncludeEmptyMeansAll — empty includes means include everything
    148 // ---------------------------------------------------------------------------
    149 func TestShouldIncludeEmptyMeansAll(t *testing.T) {
    150 	w := &Watcher{
    151 		rootPath: "/project",
    152 		includes: nil,
    153 	}
    154 
    155 	tests := []struct {
    156 		path   string
    157 		expect bool
    158 	}{
    159 		{"/project/anything/at/all.go", true},
    160 		{"/project/tmp/cache.bin", true},
    161 	}
    162 
    163 	for _, tt := range tests {
    164 		got := w.shouldInclude(tt.path)
    165 		if got != tt.expect {
    166 			t.Errorf("shouldInclude(%q) = %v, want %v", tt.path, got, tt.expect)
    167 		}
    168 	}
    169 }
    170 
    171 // ---------------------------------------------------------------------------
    172 // 7. TestFindBrokenSymlinks — detects broken symlinks in a directory
    173 // ---------------------------------------------------------------------------
    174 func TestFindBrokenSymlinks(t *testing.T) {
    175 	dir := t.TempDir()
    176 
    177 	// Create a valid file
    178 	os.WriteFile(filepath.Join(dir, "good.txt"), []byte("ok"), 0644)
    179 
    180 	// Create a broken symlink
    181 	os.Symlink("/nonexistent/target", filepath.Join(dir, "bad.txt"))
    182 
    183 	// Create a valid symlink
    184 	os.Symlink(filepath.Join(dir, "good.txt"), filepath.Join(dir, "also-good.txt"))
    185 
    186 	broken := findBrokenSymlinks(dir)
    187 
    188 	if len(broken) != 1 {
    189 		t.Fatalf("findBrokenSymlinks found %d, want 1", len(broken))
    190 	}
    191 	if broken[0].Target != "/nonexistent/target" {
    192 		t.Errorf("target = %q, want %q", broken[0].Target, "/nonexistent/target")
    193 	}
    194 	if filepath.Base(broken[0].Path) != "bad.txt" {
    195 		t.Errorf("path base = %q, want %q", filepath.Base(broken[0].Path), "bad.txt")
    196 	}
    197 }
    198 
    199 // ---------------------------------------------------------------------------
    200 // 8. TestAddRecursiveSkipsBrokenSymlinks — watcher starts despite broken symlinks
    201 // ---------------------------------------------------------------------------
    202 func TestAddRecursiveSkipsBrokenSymlinks(t *testing.T) {
    203 	dir := t.TempDir()
    204 	sub := filepath.Join(dir, "subdir")
    205 	os.Mkdir(sub, 0755)
    206 
    207 	// Create a broken symlink inside subdir
    208 	os.Symlink("/nonexistent/target", filepath.Join(sub, "broken.csv"))
    209 
    210 	w, err := New(dir, 100, nil, nil, func() {})
    211 	if err != nil {
    212 		t.Fatalf("New: %v", err)
    213 	}
    214 	defer w.Stop()
    215 
    216 	// Start should succeed despite broken symlinks
    217 	if err := w.Start(); err != nil {
    218 		t.Fatalf("Start failed: %v", err)
    219 	}
    220 
    221 	if len(w.BrokenSymlinks) != 1 {
    222 		t.Fatalf("BrokenSymlinks = %d, want 1", len(w.BrokenSymlinks))
    223 	}
    224 	if w.BrokenSymlinks[0].Target != "/nonexistent/target" {
    225 		t.Errorf("target = %q, want %q", w.BrokenSymlinks[0].Target, "/nonexistent/target")
    226 	}
    227 }