mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-06-05 23:03:55 +00:00
f07875ee82
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%)
417 lines
12 KiB
Go
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)
|
|
}
|