a73x

internal/watcher/watcher_test.go

Ref:   Size: 3.3 KiB

package watcher

import (
	"os"
	"path/filepath"
	"testing"
	"time"

	"github.com/xanderle/claudealong/internal/ignore"
)

func newTestMatcher(t *testing.T, dir string) *ignore.Matcher {
	t.Helper()
	m, err := ignore.New(dir)
	if err != nil {
		t.Fatal(err)
	}
	return m
}

func TestWatcherFiresOnInPlaceWrite(t *testing.T) {
	dir := t.TempDir()
	p := filepath.Join(dir, "a.go")
	if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
		t.Fatal(err)
	}

	w, err := New(dir, newTestMatcher(t, dir), 50*time.Millisecond)
	if err != nil {
		t.Fatal(err)
	}
	defer w.Close()

	if err := w.Start(t.Context()); err != nil {
		t.Fatal(err)
	}

	// Trigger WRITE
	time.Sleep(20 * time.Millisecond)
	if err := os.WriteFile(p, []byte("y"), 0o644); err != nil {
		t.Fatal(err)
	}

	select {
	case ev := <-w.Events():
		if ev.Path != p {
			t.Errorf("got path %q, want %q", ev.Path, p)
		}
	case <-time.After(2 * time.Second):
		t.Fatal("no event received")
	}
}

func TestWatcherFiresOnAtomicSave(t *testing.T) {
	dir := t.TempDir()
	p := filepath.Join(dir, "a.go")
	if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
		t.Fatal(err)
	}

	w, err := New(dir, newTestMatcher(t, dir), 50*time.Millisecond)
	if err != nil {
		t.Fatal(err)
	}
	defer w.Close()
	if err := w.Start(t.Context()); err != nil {
		t.Fatal(err)
	}
	time.Sleep(20 * time.Millisecond)

	// Atomic-save: write tmpfile + rename over original
	tmp := filepath.Join(dir, ".a.go.tmp")
	if err := os.WriteFile(tmp, []byte("z"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.Rename(tmp, p); err != nil {
		t.Fatal(err)
	}

	select {
	case ev := <-w.Events():
		if ev.Path != p {
			t.Errorf("got path %q, want %q", ev.Path, p)
		}
	case <-time.After(2 * time.Second):
		t.Fatal("no event after atomic save (inode re-watch broken?)")
	}
}

func TestWatcherDebouncesBurst(t *testing.T) {
	dir := t.TempDir()
	p := filepath.Join(dir, "a.go")
	if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
		t.Fatal(err)
	}
	w, err := New(dir, newTestMatcher(t, dir), 200*time.Millisecond)
	if err != nil {
		t.Fatal(err)
	}
	defer w.Close()
	if err := w.Start(t.Context()); err != nil {
		t.Fatal(err)
	}
	time.Sleep(50 * time.Millisecond)

	// Burst of writes within debounce window
	for i := range 5 {
		os.WriteFile(p, []byte{'a' + byte(i)}, 0o644)
		time.Sleep(20 * time.Millisecond)
	}

	// Drain for 500ms; expect exactly 1 event
	count := 0
	deadline := time.After(500 * time.Millisecond)
loop:
	for {
		select {
		case <-w.Events():
			count++
		case <-deadline:
			break loop
		}
	}
	if count != 1 {
		t.Errorf("got %d events, want 1 (debounced)", count)
	}
}

func TestWatcherSkipsIgnoredPaths(t *testing.T) {
	dir := t.TempDir()
	if err := os.MkdirAll(filepath.Join(dir, "node_modules"), 0o755); err != nil {
		t.Fatal(err)
	}
	p := filepath.Join(dir, "node_modules", "a.go")
	if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
		t.Fatal(err)
	}
	w, err := New(dir, newTestMatcher(t, dir), 50*time.Millisecond)
	if err != nil {
		t.Fatal(err)
	}
	defer w.Close()
	if err := w.Start(t.Context()); err != nil {
		t.Fatal(err)
	}
	time.Sleep(50 * time.Millisecond)
	os.WriteFile(p, []byte("y"), 0o644)

	select {
	case ev := <-w.Events():
		t.Fatalf("unexpected event for ignored path: %q", ev.Path)
	case <-time.After(300 * time.Millisecond):
		// pass
	}
}