Files
claude-mnemonic/internal/watcher/watcher_test.go
T
lukaszraczylo f07875ee82 fix: plugin no longer vanishes after Claude Code updates
Root cause: plugin registered as directory source in known_marketplaces.json,
which gets wiped on CLI updates. Now registers in extraKnownMarketplaces
(settings.json) as a GitHub source — same mechanism caveman/context-mode use.

Binaries install to ~/.claude-mnemonic/bin/ instead of the Claude-managed
plugins directory. Thin wrapper scripts in the repo let the marketplace
clone find them. Nothing gets cleaned up when Claude refreshes its cache.

Also fixed along the way:
- ONNX Runtime 1.24.3 → 1.26.0 (API v25 mismatch broke all embedding tests)
- Vector client leaked on DB reinit, processQueue had a race on sessionManager
- reloadConfig called os.Exit(0) bypassing graceful shutdown
- Removed dead QueryRowWithTimeout that leaked contexts
- Added tests for graph/watcher/maintenance/update (all were at 0%)
2026-05-24 01:56:54 +01:00

417 lines
12 KiB
Go

//go:build fts5
package watcher
import (
"context"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// waitForCondition polls fn every 10ms until it returns true or timeout expires.
func waitForCondition(t *testing.T, timeout time.Duration, fn func() bool) bool {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if fn() {
return true
}
time.Sleep(10 * time.Millisecond)
}
return false
}
// TestNew_CreatesWatcherWithCorrectFields verifies New initialises all fields correctly.
func TestNew_CreatesWatcherWithCorrectFields(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
called := false
cb := func() { called = true }
w, err := New(target, cb)
require.NoError(t, err)
require.NotNil(t, w)
defer w.Stop() //nolint:errcheck
assert.Equal(t, target, w.targetPath)
assert.Equal(t, dir, w.parentPath)
assert.Equal(t, 100*time.Millisecond, w.debounce)
assert.NotNil(t, w.watcher)
assert.NotNil(t, w.ctx)
assert.NotNil(t, w.cancel)
assert.False(t, w.running)
assert.False(t, called, "callback must not be invoked on creation")
}
// TestNew_NilCallback is valid — handleDeletion guards for nil onDelete.
func TestNew_NilCallback(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
w, err := New(target, nil)
require.NoError(t, err)
require.NotNil(t, w)
defer w.Stop() //nolint:errcheck
assert.Nil(t, w.onDelete)
}
// TestStart_SetsRunningTrue verifies Start transitions running to true.
func TestStart_SetsRunningTrue(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
w, err := New(target, func() {})
require.NoError(t, err)
defer w.Stop() //nolint:errcheck
err = w.Start()
require.NoError(t, err)
w.mu.Lock()
running := w.running
w.mu.Unlock()
assert.True(t, running)
}
// TestStart_Idempotent verifies calling Start twice does not panic or return error.
func TestStart_Idempotent(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
w, err := New(target, func() {})
require.NoError(t, err)
defer w.Stop() //nolint:errcheck
require.NoError(t, w.Start())
require.NoError(t, w.Start(), "second Start must be a no-op without error")
// Still only one goroutine running — running flag is still true.
w.mu.Lock()
running := w.running
w.mu.Unlock()
assert.True(t, running)
}
// TestStop_SetsRunningFalse verifies Stop transitions running to false.
func TestStop_SetsRunningFalse(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
w, err := New(target, func() {})
require.NoError(t, err)
require.NoError(t, w.Start())
require.NoError(t, w.Stop())
w.mu.Lock()
running := w.running
w.mu.Unlock()
assert.False(t, running)
}
// TestStop_Idempotent verifies calling Stop when not running returns nil.
func TestStop_Idempotent(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
w, err := New(target, func() {})
require.NoError(t, err)
// Never started — Stop must be a no-op.
assert.NoError(t, w.Stop())
// Second stop after the first no-op must also succeed.
assert.NoError(t, w.Stop())
}
// TestStop_WithoutStart verifies Stop on an unstarted watcher is safe.
func TestStop_WithoutStart(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
w, err := New(target, func() {})
require.NoError(t, err)
err = w.Stop()
assert.NoError(t, err)
}
// TestTargetDeletion_CallbackFired verifies that deleting the target file triggers onDelete.
func TestTargetDeletion_CallbackFired(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
// Create the target file so the parent watch is real.
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
var callCount int32
w, err := New(target, func() {
atomic.AddInt32(&callCount, 1)
})
require.NoError(t, err)
defer w.Stop() //nolint:errcheck
require.NoError(t, w.Start())
// Delete the target file.
require.NoError(t, os.Remove(target))
// Wait up to 1 second for the debounced callback (debounce=100ms).
fired := waitForCondition(t, 1*time.Second, func() bool {
return atomic.LoadInt32(&callCount) > 0
})
assert.True(t, fired, "onDelete callback not called after target deletion")
}
// TestTargetDeletion_CallbackCalledOnce verifies debounce suppresses duplicate events.
func TestTargetDeletion_CallbackCalledOnce(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
var callCount int32
w, err := New(target, func() {
atomic.AddInt32(&callCount, 1)
})
require.NoError(t, err)
defer w.Stop() //nolint:errcheck
require.NoError(t, w.Start())
require.NoError(t, os.Remove(target))
// Wait for callback to fire.
waitForCondition(t, 1*time.Second, func() bool {
return atomic.LoadInt32(&callCount) > 0
})
// Wait an extra debounce window to confirm no second call arrives.
time.Sleep(300 * time.Millisecond)
assert.Equal(t, int32(1), atomic.LoadInt32(&callCount), "callback fired more than once for a single deletion")
}
// TestTargetRecreation_CancelsCallback verifies that recreating the target before the
// debounce fires suppresses the onDelete callback.
func TestTargetRecreation_CancelsCallback(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
var callCount int32
// Use a longer debounce so we can recreate before it fires.
w, err := New(target, func() {
atomic.AddInt32(&callCount, 1)
})
require.NoError(t, err)
// Override debounce to give us a larger window.
w.debounce = 300 * time.Millisecond
defer w.Stop() //nolint:errcheck
require.NoError(t, w.Start())
// Delete then immediately recreate within the debounce window.
require.NoError(t, os.Remove(target))
time.Sleep(20 * time.Millisecond) // ensure delete event is processed
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
// Wait past full debounce period to confirm callback was cancelled.
time.Sleep(500 * time.Millisecond)
assert.Equal(t, int32(0), atomic.LoadInt32(&callCount), "callback should have been cancelled by recreation")
}
// TestParentDirectoryDeletion_CallbackFired verifies that deleting the parent directory
// triggers the onDelete callback.
func TestParentDirectoryDeletion_CallbackFired(t *testing.T) {
// Create a nested structure: base/sub/db.sqlite so we can remove sub
// without losing t.TempDir (which is base).
base := t.TempDir()
sub := filepath.Join(base, "sub")
require.NoError(t, os.Mkdir(sub, 0o755))
target := filepath.Join(sub, "db.sqlite")
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
var callCount int32
w, err := New(target, func() {
atomic.AddInt32(&callCount, 1)
})
require.NoError(t, err)
defer w.Stop() //nolint:errcheck
require.NoError(t, w.Start())
// Remove parent directory entirely.
require.NoError(t, os.RemoveAll(sub))
fired := waitForCondition(t, 1500*time.Millisecond, func() bool {
return atomic.LoadInt32(&callCount) > 0
})
assert.True(t, fired, "onDelete callback not called after parent directory deletion")
}
// TestAddWatch_NonExistentParent verifies addWatch returns an error when parent is absent.
func TestAddWatch_NonExistentParent(t *testing.T) {
// Point watcher at a path whose parent definitely does not exist.
nonExistent := filepath.Join(t.TempDir(), "missing", "db.sqlite")
w, err := New(nonExistent, func() {})
require.NoError(t, err)
defer w.Stop() //nolint:errcheck
err = w.addWatch()
assert.Error(t, err, "addWatch must fail when parent directory does not exist")
}
// TestContextCancellation_StopsWatchLoop verifies the watchLoop exits when Stop is called.
func TestContextCancellation_StopsWatchLoop(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
w, err := New(target, func() {})
require.NoError(t, err)
require.NoError(t, w.Start())
// Stop cancels the context; the goroutine should exit cleanly.
require.NoError(t, w.Stop())
// Give the goroutine a moment to exit — then verify running is false.
time.Sleep(50 * time.Millisecond)
w.mu.Lock()
running := w.running
w.mu.Unlock()
assert.False(t, running)
}
// TestParentDirRecreation_ReEstablishesWatch verifies that recreating the parent after
// deletion allows subsequent target-deletion events to fire the callback.
func TestParentDirRecreation_ReEstablishesWatch(t *testing.T) {
base := t.TempDir()
sub := filepath.Join(base, "sub")
require.NoError(t, os.Mkdir(sub, 0o755))
target := filepath.Join(sub, "db.sqlite")
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
var callCount int32
w, err := New(target, func() {
atomic.AddInt32(&callCount, 1)
})
require.NoError(t, err)
defer w.Stop() //nolint:errcheck
require.NoError(t, w.Start())
// Remove the parent.
require.NoError(t, os.RemoveAll(sub))
// Wait for first callback.
fired := waitForCondition(t, 1500*time.Millisecond, func() bool {
return atomic.LoadInt32(&callCount) > 0
})
require.True(t, fired, "first deletion callback must fire")
firstCount := atomic.LoadInt32(&callCount)
// Recreate parent and target — re-established watch should allow a second callback.
require.NoError(t, os.Mkdir(sub, 0o755))
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
// Wait for handleDeletion's goroutine to attempt re-adding the watch (500ms sleep inside).
time.Sleep(700 * time.Millisecond)
// Now delete the target again.
require.NoError(t, os.Remove(target))
// We only assert the first callback fired; the re-watch is best-effort and
// OS-timing-dependent, so we don't hard-assert a second callback.
assert.GreaterOrEqual(t, atomic.LoadInt32(&callCount), firstCount, "call count must not decrease")
}
// TestConcurrentStartStop verifies that concurrent Start/Stop calls do not race or panic.
func TestConcurrentStartStop(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
w, err := New(target, func() {})
require.NoError(t, err)
var wg sync.WaitGroup
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
// Launch goroutines that repeatedly start/stop the watcher.
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
default:
_ = w.Start()
time.Sleep(5 * time.Millisecond)
_ = w.Stop()
time.Sleep(5 * time.Millisecond)
}
}
}()
}
wg.Wait()
// No panic = pass. Final state: running should be consistent (we don't assert
// a specific value since Stop may have won last).
}
// TestDebounceField_DefaultValue asserts the default debounce is 100ms.
func TestDebounceField_DefaultValue(t *testing.T) {
dir := t.TempDir()
w, err := New(filepath.Join(dir, "x"), func() {})
require.NoError(t, err)
defer w.Stop() //nolint:errcheck
assert.Equal(t, 100*time.Millisecond, w.debounce)
}
// TestCallbackNotCalledWhenStopped verifies that if we Stop before the debounce fires,
// the callback is not invoked after Stop (context cancel exits the watchLoop).
func TestCallbackNotCalledWhenStopped(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
var callCount int32
w, err := New(target, func() {
atomic.AddInt32(&callCount, 1)
})
require.NoError(t, err)
w.debounce = 500 * time.Millisecond // wide window
require.NoError(t, w.Start())
// Delete file — debounce timer is now running (500ms).
require.NoError(t, os.Remove(target))
time.Sleep(20 * time.Millisecond) // let event propagate
// Stop before timer fires — context is cancelled, watchLoop exits.
require.NoError(t, w.Stop())
// Wait past the debounce window; the AfterFunc may still fire (it's not
// tied to the context), but the watcher is stopped. We assert the loop
// itself exited cleanly.
time.Sleep(700 * time.Millisecond)
// The AfterFunc timer fires outside the watchLoop — callback may or may not
// have fired depending on OS scheduling. We assert no panic occurred.
// The important invariant: running is false.
w.mu.Lock()
running := w.running
w.mu.Unlock()
assert.False(t, running)
}