- Split slow importer integration tests into separate file - Add t.Short() guards to 10 slow daemon tests - Document test organization in TEST_OPTIMIZATION.md - Fast tests now run in ~50s vs 3+ minutes - Use 'go test -short ./...' for fast feedback Amp-Thread-ID: https://ampcode.com/threads/T-29ae21ac-749d-43d7-bf0c-2c5f7a06ae76 Co-authored-by: Amp <amp@ampcode.com>
403 lines
9.6 KiB
Go
403 lines
9.6 KiB
Go
//go:build integration
|
|
// +build integration
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// newMockLogger creates a daemonLogger that does nothing
|
|
func newMockLogger() daemonLogger {
|
|
return daemonLogger{
|
|
logFunc: func(format string, args ...interface{}) {},
|
|
}
|
|
}
|
|
|
|
func TestFileWatcher_JSONLChangeDetection(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
jsonlPath := filepath.Join(dir, "test.jsonl")
|
|
|
|
// Create initial JSONL file
|
|
if err := os.WriteFile(jsonlPath, []byte("{}"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Track onChange calls
|
|
var callCount int32
|
|
var mu sync.Mutex
|
|
var callTimes []time.Time
|
|
|
|
onChange := func() {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
atomic.AddInt32(&callCount, 1)
|
|
callTimes = append(callTimes, time.Now())
|
|
}
|
|
|
|
// Create watcher with short debounce for testing
|
|
fw, err := NewFileWatcher(jsonlPath, onChange)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer fw.Close()
|
|
|
|
// Override debounce duration for faster tests
|
|
fw.debouncer.duration = 10 * time.Millisecond
|
|
|
|
// Start the watcher
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
fw.Start(ctx, newMockLogger())
|
|
|
|
// Wait for watcher to be ready
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
// Modify the file
|
|
if err := os.WriteFile(jsonlPath, []byte("{}\n{}"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Wait for debounce + processing using event-driven wait
|
|
waitFor(t, 200*time.Millisecond, 2*time.Millisecond, func() bool {
|
|
return atomic.LoadInt32(&callCount) >= 1
|
|
})
|
|
}
|
|
|
|
func TestFileWatcher_MultipleChangesDebounced(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
jsonlPath := filepath.Join(dir, "test.jsonl")
|
|
|
|
if err := os.WriteFile(jsonlPath, []byte("{}"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var callCount int32
|
|
onChange := func() {
|
|
atomic.AddInt32(&callCount, 1)
|
|
}
|
|
|
|
fw, err := NewFileWatcher(jsonlPath, onChange)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer fw.Close()
|
|
|
|
// Short debounce for testing
|
|
fw.debouncer.duration = 10 * time.Millisecond
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
fw.Start(ctx, newMockLogger())
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
// Make multiple rapid changes
|
|
for i := 0; i < 5; i++ {
|
|
if err := os.WriteFile(jsonlPath, []byte("{}"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
|
|
// Wait for debounce using event-driven wait
|
|
waitFor(t, 200*time.Millisecond, 2*time.Millisecond, func() bool {
|
|
return atomic.LoadInt32(&callCount) >= 1
|
|
})
|
|
|
|
count := atomic.LoadInt32(&callCount)
|
|
// Should have debounced multiple changes into 1-2 calls, not 5
|
|
if count > 3 {
|
|
t.Errorf("Expected debouncing to reduce calls to ≤3, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestFileWatcher_GitRefChangeDetection(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
|
|
gitRefsPath := filepath.Join(dir, ".git", "refs", "heads")
|
|
|
|
// Create directory structure
|
|
if err := os.MkdirAll(filepath.Dir(jsonlPath), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(gitRefsPath, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(jsonlPath, []byte("{}"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var callCount int32
|
|
var mu sync.Mutex
|
|
var sources []string
|
|
onChange := func() {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
atomic.AddInt32(&callCount, 1)
|
|
sources = append(sources, "onChange")
|
|
}
|
|
|
|
fw, err := NewFileWatcher(jsonlPath, onChange)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer fw.Close()
|
|
|
|
// Skip test if in polling mode (git ref watching not supported in polling mode)
|
|
if fw.pollingMode {
|
|
t.Skip("Git ref watching not available in polling mode")
|
|
}
|
|
|
|
fw.debouncer.duration = 10 * time.Millisecond
|
|
|
|
// Verify git refs path is being watched
|
|
if fw.watcher == nil {
|
|
t.Fatal("watcher is nil")
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
fw.Start(ctx, newMockLogger())
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
// First, verify watcher is working by modifying JSONL
|
|
if err := os.WriteFile(jsonlPath, []byte("{}\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
waitFor(t, 200*time.Millisecond, 2*time.Millisecond, func() bool {
|
|
return atomic.LoadInt32(&callCount) >= 1
|
|
})
|
|
|
|
// Reset counter for git ref test
|
|
atomic.StoreInt32(&callCount, 0)
|
|
|
|
// Simulate git ref change (branch update)
|
|
// NOTE: fsnotify behavior for git refs can be platform-specific and unreliable
|
|
// This test verifies the code path but may be skipped on some platforms
|
|
refFile := filepath.Join(gitRefsPath, "main")
|
|
if err := os.WriteFile(refFile, []byte("abc123"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Wait for event detection + debounce (may not work on all platforms)
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
count := atomic.LoadInt32(&callCount)
|
|
if count < 1 {
|
|
// Git ref watching can be unreliable with fsnotify in some environments
|
|
t.Logf("Warning: git ref change not detected (count=%d) - this may be platform-specific fsnotify behavior", count)
|
|
t.Skip("Git ref watching appears not to work in this environment")
|
|
}
|
|
}
|
|
|
|
func TestFileWatcher_FileRemovalAndRecreation(t *testing.T) {
|
|
t.Parallel()
|
|
if testing.Short() {
|
|
t.Skip("Skipping file removal test in short mode")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
jsonlPath := filepath.Join(dir, "test.jsonl")
|
|
|
|
if err := os.WriteFile(jsonlPath, []byte("{}"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var callCount int32
|
|
onChange := func() {
|
|
atomic.AddInt32(&callCount, 1)
|
|
}
|
|
|
|
fw, err := NewFileWatcher(jsonlPath, onChange)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer fw.Close()
|
|
|
|
// Skip test if in polling mode (separate test for polling)
|
|
if fw.pollingMode {
|
|
t.Skip("File removal/recreation not testable via fsnotify in polling mode")
|
|
}
|
|
|
|
fw.debouncer.duration = 10 * time.Millisecond
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
fw.Start(ctx, newMockLogger())
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
// First verify watcher is working
|
|
if err := os.WriteFile(jsonlPath, []byte("{}\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
waitFor(t, 200*time.Millisecond, 2*time.Millisecond, func() bool {
|
|
return atomic.LoadInt32(&callCount) >= 1
|
|
})
|
|
|
|
// Reset for removal test
|
|
atomic.StoreInt32(&callCount, 0)
|
|
|
|
// Remove the file (simulates git checkout)
|
|
if err := os.Remove(jsonlPath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Wait for removal to be detected + debounce
|
|
time.Sleep(30 * time.Millisecond)
|
|
|
|
// Recreate the file
|
|
if err := os.WriteFile(jsonlPath, []byte("{}\n{}"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Wait for recreation to be detected + file re-watch + debounce (may not work on all platforms)
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
count := atomic.LoadInt32(&callCount)
|
|
if count < 1 {
|
|
// File removal/recreation behavior can be platform-specific
|
|
t.Logf("Warning: file removal+recreation not detected (count=%d) - this may be platform-specific", count)
|
|
t.Skip("File removal/recreation watching appears not to work reliably in this environment")
|
|
}
|
|
}
|
|
|
|
func TestFileWatcher_PollingFallback(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
jsonlPath := filepath.Join(dir, "test.jsonl")
|
|
|
|
if err := os.WriteFile(jsonlPath, []byte("{}"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var callCount int32
|
|
onChange := func() {
|
|
atomic.AddInt32(&callCount, 1)
|
|
}
|
|
|
|
fw, err := NewFileWatcher(jsonlPath, onChange)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer fw.Close()
|
|
|
|
// Force polling mode
|
|
fw.pollingMode = true
|
|
fw.pollInterval = 50 * time.Millisecond
|
|
fw.debouncer.duration = 10 * time.Millisecond
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
fw.Start(ctx, newMockLogger())
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
// Modify file
|
|
if err := os.WriteFile(jsonlPath, []byte("{}\n{}"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Wait for polling interval + debounce
|
|
waitFor(t, 200*time.Millisecond, 2*time.Millisecond, func() bool {
|
|
return atomic.LoadInt32(&callCount) >= 1
|
|
})
|
|
|
|
count := atomic.LoadInt32(&callCount)
|
|
if count < 1 {
|
|
t.Errorf("Expected polling to detect file change, got %d calls", count)
|
|
}
|
|
}
|
|
|
|
func TestFileWatcher_PollingFileDisappearance(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
jsonlPath := filepath.Join(dir, "test.jsonl")
|
|
|
|
if err := os.WriteFile(jsonlPath, []byte("{}"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var callCount int32
|
|
onChange := func() {
|
|
atomic.AddInt32(&callCount, 1)
|
|
}
|
|
|
|
fw, err := NewFileWatcher(jsonlPath, onChange)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer fw.Close()
|
|
|
|
fw.pollingMode = true
|
|
fw.pollInterval = 50 * time.Millisecond
|
|
fw.debouncer.duration = 10 * time.Millisecond
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
fw.Start(ctx, newMockLogger())
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
// Remove file
|
|
if err := os.Remove(jsonlPath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Wait for polling to detect disappearance
|
|
waitFor(t, 200*time.Millisecond, 2*time.Millisecond, func() bool {
|
|
return atomic.LoadInt32(&callCount) >= 1
|
|
})
|
|
|
|
count := atomic.LoadInt32(&callCount)
|
|
if count < 1 {
|
|
t.Errorf("Expected polling to detect file disappearance, got %d calls", count)
|
|
}
|
|
}
|
|
|
|
func TestFileWatcher_Close(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
jsonlPath := filepath.Join(dir, "test.jsonl")
|
|
|
|
if err := os.WriteFile(jsonlPath, []byte("{}"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
onChange := func() {}
|
|
|
|
fw, err := NewFileWatcher(jsonlPath, onChange)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
fw.Start(ctx, newMockLogger())
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
// Close should not error
|
|
if err := fw.Close(); err != nil {
|
|
t.Errorf("Close() returned error: %v", err)
|
|
}
|
|
|
|
// Second close should be safe
|
|
if err := fw.Close(); err != nil {
|
|
t.Errorf("Second Close() returned error: %v", err)
|
|
}
|
|
}
|