mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-23 23:18:05 -05:00
tests(plugins): optimize tests
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -3,7 +3,6 @@ package plugins
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
@@ -15,65 +14,43 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Manager", func() {
|
||||
var _ = Describe("Manager", Ordered, func() {
|
||||
var (
|
||||
manager *Manager
|
||||
ctx context.Context
|
||||
testdataDir string
|
||||
tmpDir string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
BeforeAll(func() {
|
||||
ctx = context.Background()
|
||||
|
||||
// Get testdata directory
|
||||
// Get testdata directory (where fake-metadata-agent.wasm lives)
|
||||
_, currentFile, _, ok := runtime.Caller(0)
|
||||
Expect(ok).To(BeTrue())
|
||||
testdataDir = filepath.Join(filepath.Dir(currentFile), "testdata")
|
||||
|
||||
// Create temp dir for plugins
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "plugins-test-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Setup config
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = tmpDir
|
||||
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
||||
conf.Server.Plugins.Folder = testdataDir
|
||||
|
||||
// Create a fresh manager for each test
|
||||
// Create manager once for all tests
|
||||
manager = &Manager{
|
||||
plugins: make(map[string]*pluginInstance),
|
||||
}
|
||||
err = manager.Start(ctx)
|
||||
err := manager.Start(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
DeferCleanup(func() {
|
||||
_ = manager.Stop()
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
})
|
||||
})
|
||||
|
||||
copyTestPlugin := func(destName string) string {
|
||||
srcPath := filepath.Join(testdataDir, "test-plugin.wasm")
|
||||
destPath := filepath.Join(tmpDir, destName+".wasm")
|
||||
data, err := os.ReadFile(srcPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = os.WriteFile(destPath, data, 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return destPath
|
||||
}
|
||||
|
||||
Describe("LoadPlugin", func() {
|
||||
It("loads a new plugin by name", func() {
|
||||
copyTestPlugin("new-plugin")
|
||||
|
||||
err := manager.LoadPlugin("new-plugin")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
It("auto-loads plugins from folder on Start", func() {
|
||||
// Plugin is already loaded by manager.Start() via discoverPlugins
|
||||
names := manager.PluginNames(string(CapabilityMetadataAgent))
|
||||
Expect(names).To(ContainElement("new-plugin"))
|
||||
Expect(names).To(ContainElement("fake-metadata-agent"))
|
||||
})
|
||||
|
||||
It("returns error when plugin file does not exist", func() {
|
||||
@@ -83,19 +60,21 @@ var _ = Describe("Manager", func() {
|
||||
})
|
||||
|
||||
It("returns error when plugin is already loaded", func() {
|
||||
copyTestPlugin("duplicate")
|
||||
|
||||
err := manager.LoadPlugin("duplicate")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = manager.LoadPlugin("duplicate")
|
||||
// Plugin was loaded on Start, try to load again
|
||||
err := manager.LoadPlugin("fake-metadata-agent")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("already loaded"))
|
||||
})
|
||||
|
||||
It("returns error when plugins folder is not configured", func() {
|
||||
originalFolder := conf.Server.Plugins.Folder
|
||||
originalDataFolder := conf.Server.DataFolder
|
||||
conf.Server.Plugins.Folder = ""
|
||||
conf.Server.DataFolder = ""
|
||||
defer func() {
|
||||
conf.Server.Plugins.Folder = originalFolder
|
||||
conf.Server.DataFolder = originalDataFolder
|
||||
}()
|
||||
|
||||
err := manager.LoadPlugin("test")
|
||||
Expect(err).To(HaveOccurred())
|
||||
@@ -105,15 +84,21 @@ var _ = Describe("Manager", func() {
|
||||
|
||||
Describe("UnloadPlugin", func() {
|
||||
It("removes a loaded plugin", func() {
|
||||
copyTestPlugin("to-unload")
|
||||
err := manager.LoadPlugin("to-unload")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = manager.UnloadPlugin("to-unload")
|
||||
// Plugin is already loaded from Start
|
||||
err := manager.UnloadPlugin("fake-metadata-agent")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
names := manager.PluginNames(string(CapabilityMetadataAgent))
|
||||
Expect(names).ToNot(ContainElement("to-unload"))
|
||||
Expect(names).ToNot(ContainElement("fake-metadata-agent"))
|
||||
})
|
||||
|
||||
It("can reload after unload", func() {
|
||||
// Reload the plugin we just unloaded
|
||||
err := manager.LoadPlugin("fake-metadata-agent")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
names := manager.PluginNames(string(CapabilityMetadataAgent))
|
||||
Expect(names).To(ContainElement("fake-metadata-agent"))
|
||||
})
|
||||
|
||||
It("returns error when plugin not found", func() {
|
||||
@@ -125,15 +110,11 @@ var _ = Describe("Manager", func() {
|
||||
|
||||
Describe("ReloadPlugin", func() {
|
||||
It("unloads and reloads a plugin", func() {
|
||||
copyTestPlugin("to-reload")
|
||||
err := manager.LoadPlugin("to-reload")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = manager.ReloadPlugin("to-reload")
|
||||
err := manager.ReloadPlugin("fake-metadata-agent")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
names := manager.PluginNames(string(CapabilityMetadataAgent))
|
||||
Expect(names).To(ContainElement("to-reload"))
|
||||
Expect(names).To(ContainElement("fake-metadata-agent"))
|
||||
})
|
||||
|
||||
It("returns error when plugin not found", func() {
|
||||
@@ -141,31 +122,10 @@ var _ = Describe("Manager", func() {
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("failed to unload"))
|
||||
})
|
||||
|
||||
It("keeps plugin unloaded if reload fails", func() {
|
||||
copyTestPlugin("fail-reload")
|
||||
err := manager.LoadPlugin("fail-reload")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Remove the wasm file so reload will fail
|
||||
wasmPath := filepath.Join(tmpDir, "fail-reload.wasm")
|
||||
err = os.Remove(wasmPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = manager.ReloadPlugin("fail-reload")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("failed to reload"))
|
||||
|
||||
// Plugin should no longer be loaded
|
||||
names := manager.PluginNames(string(CapabilityMetadataAgent))
|
||||
Expect(names).ToNot(ContainElement("fail-reload"))
|
||||
})
|
||||
})
|
||||
|
||||
It("can call the plugin concurrently", func() {
|
||||
copyTestPlugin("new-plugin")
|
||||
err := manager.LoadPlugin("new-plugin")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Plugin is already loaded
|
||||
|
||||
const concurrency = 100
|
||||
errs := make(chan error, concurrency)
|
||||
@@ -176,7 +136,7 @@ var _ = Describe("Manager", func() {
|
||||
for i := range concurrency {
|
||||
go func(i int) {
|
||||
defer g.Done()
|
||||
a, ok := manager.LoadMediaAgent("new-plugin")
|
||||
a, ok := manager.LoadMediaAgent("fake-metadata-agent")
|
||||
Expect(ok).To(BeTrue())
|
||||
agent := a.(agents.ArtistBiographyRetriever)
|
||||
bio, err := agent.GetArtistBiography(ctx, fmt.Sprintf("artist-%d", i), fmt.Sprintf("Artist %d", i), "")
|
||||
@@ -199,5 +159,4 @@ var _ = Describe("Manager", func() {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -36,8 +36,8 @@ var _ = Describe("MetadataAgent", Ordered, func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Copy test plugin to temp dir
|
||||
srcPath := filepath.Join(testdataDir, "test-plugin.wasm")
|
||||
destPath := filepath.Join(tmpDir, "test-plugin.wasm")
|
||||
srcPath := filepath.Join(testdataDir, "fake-metadata-agent.wasm")
|
||||
destPath := filepath.Join(tmpDir, "fake-metadata-agent.wasm")
|
||||
data, err := os.ReadFile(srcPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = os.WriteFile(destPath, data, 0600)
|
||||
@@ -58,7 +58,7 @@ var _ = Describe("MetadataAgent", Ordered, func() {
|
||||
|
||||
// Load the agent via manager
|
||||
var ok2 bool
|
||||
agent, ok2 = manager.LoadMediaAgent("test-plugin")
|
||||
agent, ok2 = manager.LoadMediaAgent("fake-metadata-agent")
|
||||
Expect(ok2).To(BeTrue())
|
||||
|
||||
DeferCleanup(func() {
|
||||
@@ -69,7 +69,7 @@ var _ = Describe("MetadataAgent", Ordered, func() {
|
||||
|
||||
Describe("AgentName", func() {
|
||||
It("returns the plugin name", func() {
|
||||
Expect(agent.AgentName()).To(Equal("test-plugin"))
|
||||
Expect(agent.AgentName()).To(Equal("fake-metadata-agent"))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
const testDataDir = "plugins/testdata"
|
||||
|
||||
func TestPlugins(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
buildTestPlugins(t, testDataDir)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Plugins Suite")
|
||||
}
|
||||
|
||||
func buildTestPlugins(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
t.Logf("[BeforeSuite] Current working directory: %s", path)
|
||||
cmd := exec.Command("make", "-C", path)
|
||||
out, err := cmd.CombinedOutput()
|
||||
t.Logf("[BeforeSuite] Make output: %s", string(out))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to build test plugins: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
11
plugins/testdata/Makefile
vendored
Normal file
11
plugins/testdata/Makefile
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# Build fake sample plugins used for testing
|
||||
# Auto-discover all plugin folders (folders containing go.mod)
|
||||
PLUGINS := $(patsubst %/go.mod,%,$(wildcard */go.mod))
|
||||
|
||||
all: $(PLUGINS:%=%.wasm)
|
||||
|
||||
clean:
|
||||
rm -f $(PLUGINS:%=%.wasm)
|
||||
|
||||
%.wasm: %/main.go %/go.mod
|
||||
cd $* && GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o ../$@ .
|
||||
@@ -106,6 +106,38 @@ func (m *Manager) handleWatcherEvent(event notify.EventInfo) {
|
||||
m.debounceMu.Unlock()
|
||||
}
|
||||
|
||||
// pluginAction represents the action to take on a plugin based on a file event
|
||||
type pluginAction int
|
||||
|
||||
const (
|
||||
actionNone pluginAction = iota // No action needed
|
||||
actionLoad // Load the plugin
|
||||
actionUnload // Unload the plugin
|
||||
actionReload // Reload the plugin
|
||||
)
|
||||
|
||||
// determinePluginAction decides what action to take based on the file event type
|
||||
// and whether the plugin is currently loaded. This is a pure function with no side effects.
|
||||
func determinePluginAction(eventType notify.Event, isLoaded bool) pluginAction {
|
||||
switch {
|
||||
case eventType¬ify.Remove != 0 || eventType¬ify.Rename != 0:
|
||||
// File removed or renamed away - unload if loaded
|
||||
return actionUnload
|
||||
|
||||
case eventType¬ify.Create != 0:
|
||||
// New file - load it
|
||||
return actionLoad
|
||||
|
||||
case eventType¬ify.Write != 0:
|
||||
// File modified - reload if loaded, otherwise load
|
||||
if isLoaded {
|
||||
return actionReload
|
||||
}
|
||||
return actionLoad
|
||||
}
|
||||
return actionNone
|
||||
}
|
||||
|
||||
// processPluginEvent handles the actual plugin load/unload/reload after debouncing
|
||||
func (m *Manager) processPluginEvent(pluginName string, eventType notify.Event) {
|
||||
// Don't process if manager is stopping/stopped (atomic check to avoid race with Stop())
|
||||
@@ -118,35 +150,25 @@ func (m *Manager) processPluginEvent(pluginName string, eventType notify.Event)
|
||||
delete(m.debounceTimers, pluginName)
|
||||
m.debounceMu.Unlock()
|
||||
|
||||
switch {
|
||||
case eventType¬ify.Remove != 0 || eventType¬ify.Rename != 0:
|
||||
// File removed or renamed away - unload if loaded
|
||||
// Check if plugin is currently loaded
|
||||
m.mu.RLock()
|
||||
_, isLoaded := m.plugins[pluginName]
|
||||
m.mu.RUnlock()
|
||||
|
||||
// Determine and execute the appropriate action
|
||||
action := determinePluginAction(eventType, isLoaded)
|
||||
switch action {
|
||||
case actionLoad:
|
||||
if err := m.LoadPlugin(pluginName); err != nil {
|
||||
log.Error(m.ctx, "Failed to load plugin", "plugin", pluginName, err)
|
||||
}
|
||||
case actionUnload:
|
||||
if err := m.UnloadPlugin(pluginName); err != nil {
|
||||
// Plugin may not have been loaded, that's okay
|
||||
log.Debug(m.ctx, "Plugin not loaded, skipping unload", "plugin", pluginName, err)
|
||||
}
|
||||
|
||||
case eventType¬ify.Create != 0:
|
||||
// New file - load it
|
||||
if err := m.LoadPlugin(pluginName); err != nil {
|
||||
log.Error(m.ctx, "Failed to load new plugin", "plugin", pluginName, err)
|
||||
}
|
||||
|
||||
case eventType¬ify.Write != 0:
|
||||
// File modified - check if it's loaded and reload
|
||||
m.mu.RLock()
|
||||
_, isLoaded := m.plugins[pluginName]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if isLoaded {
|
||||
if err := m.ReloadPlugin(pluginName); err != nil {
|
||||
log.Error(m.ctx, "Failed to reload plugin", "plugin", pluginName, err)
|
||||
}
|
||||
} else {
|
||||
// Not loaded yet, try to load it (might be a new file that was written after create)
|
||||
if err := m.LoadPlugin(pluginName); err != nil {
|
||||
log.Error(m.ctx, "Failed to load plugin", "plugin", pluginName, err)
|
||||
}
|
||||
case actionReload:
|
||||
if err := m.ReloadPlugin(pluginName); err != nil {
|
||||
log.Error(m.ctx, "Failed to reload plugin", "plugin", pluginName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
125
plugins/watcher_integration_test.go
Normal file
125
plugins/watcher_integration_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/rjeczalik/notify"
|
||||
)
|
||||
|
||||
var _ = Describe("Watcher Integration", Ordered, func() {
|
||||
var (
|
||||
manager *Manager
|
||||
ctx context.Context
|
||||
testdataDir string
|
||||
tmpDir string
|
||||
)
|
||||
|
||||
BeforeAll(func() {
|
||||
if testing.Short() {
|
||||
Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
// Get testdata directory
|
||||
_, currentFile, _, ok := runtime.Caller(0)
|
||||
Expect(ok).To(BeTrue())
|
||||
testdataDir = filepath.Join(filepath.Dir(currentFile), "testdata")
|
||||
|
||||
// Create temp dir for plugins
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "plugins-watcher-integration-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Setup config (AutoReload disabled - tests inject events directly)
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = tmpDir
|
||||
conf.Server.Plugins.AutoReload = false
|
||||
|
||||
// Create a fresh manager for each test
|
||||
manager = &Manager{
|
||||
plugins: make(map[string]*pluginInstance),
|
||||
}
|
||||
err = manager.Start(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
DeferCleanup(func() {
|
||||
_ = manager.Stop()
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
})
|
||||
})
|
||||
|
||||
// Helper to copy test plugin into the temp folder
|
||||
copyTestPlugin := func() {
|
||||
srcPath := filepath.Join(testdataDir, "fake-metadata-agent.wasm")
|
||||
destPath := filepath.Join(tmpDir, "fake-metadata-agent.wasm")
|
||||
data, err := os.ReadFile(srcPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = os.WriteFile(destPath, data, 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
Describe("Plugin event processing (integration)", func() {
|
||||
// These tests verify the full flow with actual WASM plugin loading.
|
||||
|
||||
AfterEach(func() {
|
||||
// Clean up: unload plugin if loaded, remove copied file
|
||||
_ = manager.UnloadPlugin("fake-metadata-agent")
|
||||
_ = os.Remove(filepath.Join(tmpDir, "fake-metadata-agent.wasm"))
|
||||
})
|
||||
|
||||
It("loads a plugin on CREATE event", func() {
|
||||
copyTestPlugin()
|
||||
manager.processPluginEvent("fake-metadata-agent", notify.Create)
|
||||
Expect(manager.PluginNames(string(CapabilityMetadataAgent))).To(ContainElement("fake-metadata-agent"))
|
||||
})
|
||||
|
||||
It("reloads a plugin on WRITE event", func() {
|
||||
copyTestPlugin()
|
||||
err := manager.LoadPlugin("fake-metadata-agent")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
manager.processPluginEvent("fake-metadata-agent", notify.Write)
|
||||
Expect(manager.PluginNames(string(CapabilityMetadataAgent))).To(ContainElement("fake-metadata-agent"))
|
||||
})
|
||||
|
||||
It("unloads a plugin on REMOVE event", func() {
|
||||
copyTestPlugin()
|
||||
err := manager.LoadPlugin("fake-metadata-agent")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
manager.processPluginEvent("fake-metadata-agent", notify.Remove)
|
||||
Expect(manager.PluginNames(string(CapabilityMetadataAgent))).ToNot(ContainElement("fake-metadata-agent"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Watcher lifecycle", func() {
|
||||
It("does not start file watcher when AutoReload is disabled", func() {
|
||||
Expect(manager.watcherEvents).To(BeNil())
|
||||
Expect(manager.watcherDone).To(BeNil())
|
||||
})
|
||||
|
||||
It("starts file watcher when AutoReload is enabled", func() {
|
||||
_ = manager.Stop()
|
||||
|
||||
conf.Server.Plugins.AutoReload = true
|
||||
manager = &Manager{
|
||||
plugins: make(map[string]*pluginInstance),
|
||||
}
|
||||
err := manager.Start(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(manager.watcherEvents).ToNot(BeNil())
|
||||
Expect(manager.watcherDone).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,156 +1,41 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/rjeczalik/notify"
|
||||
)
|
||||
|
||||
var _ = Describe("Watcher", func() {
|
||||
var (
|
||||
manager *Manager
|
||||
ctx context.Context
|
||||
testdataDir string
|
||||
tmpDir string
|
||||
)
|
||||
Describe("determinePluginAction", func() {
|
||||
// These are fast unit tests for the pure routing logic.
|
||||
// No WASM compilation, no file I/O - runs in microseconds.
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
DescribeTable("returns correct action for event type and loaded state",
|
||||
func(eventType notify.Event, isLoaded bool, expected pluginAction) {
|
||||
Expect(determinePluginAction(eventType, isLoaded)).To(Equal(expected))
|
||||
},
|
||||
// CREATE events - always load
|
||||
Entry("CREATE when not loaded", notify.Create, false, actionLoad),
|
||||
Entry("CREATE when loaded", notify.Create, true, actionLoad),
|
||||
|
||||
// Use shorter debounce for faster tests
|
||||
originalDebounce := debounceDuration
|
||||
debounceDuration = 50 * time.Millisecond
|
||||
DeferCleanup(func() { debounceDuration = originalDebounce })
|
||||
// WRITE events - reload if loaded, load if not
|
||||
Entry("WRITE when not loaded", notify.Write, false, actionLoad),
|
||||
Entry("WRITE when loaded", notify.Write, true, actionReload),
|
||||
|
||||
// Get testdata directory
|
||||
_, currentFile, _, ok := runtime.Caller(0)
|
||||
Expect(ok).To(BeTrue())
|
||||
testdataDir = filepath.Join(filepath.Dir(currentFile), "testdata")
|
||||
// REMOVE events - always unload
|
||||
Entry("REMOVE when not loaded", notify.Remove, false, actionUnload),
|
||||
Entry("REMOVE when loaded", notify.Remove, true, actionUnload),
|
||||
|
||||
// Create temp dir for plugins
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "plugins-watcher-test-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// RENAME events - treated same as REMOVE
|
||||
Entry("RENAME when not loaded", notify.Rename, false, actionUnload),
|
||||
Entry("RENAME when loaded", notify.Rename, true, actionUnload),
|
||||
)
|
||||
|
||||
// Setup config with AutoReload enabled
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = tmpDir
|
||||
conf.Server.Plugins.AutoReload = true
|
||||
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
||||
|
||||
// Create a fresh manager for each test
|
||||
manager = &Manager{
|
||||
plugins: make(map[string]*pluginInstance),
|
||||
}
|
||||
err = manager.Start(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
DeferCleanup(func() {
|
||||
_ = manager.Stop()
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
})
|
||||
})
|
||||
|
||||
copyTestPlugin := func(destName string) string {
|
||||
srcPath := filepath.Join(testdataDir, "test-plugin.wasm")
|
||||
destPath := filepath.Join(tmpDir, destName+".wasm")
|
||||
data, err := os.ReadFile(srcPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = os.WriteFile(destPath, data, 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return destPath
|
||||
}
|
||||
|
||||
Describe("Auto-reload via file watcher", func() {
|
||||
It("loads a plugin when a new wasm file is created", func() {
|
||||
// Copy plugin file to trigger CREATE event
|
||||
copyTestPlugin("watch-create")
|
||||
|
||||
// Wait for debounce + processing
|
||||
Eventually(func() []string {
|
||||
return manager.PluginNames(string(CapabilityMetadataAgent))
|
||||
}, 1*time.Second, 50*time.Millisecond).Should(ContainElement("watch-create"))
|
||||
})
|
||||
|
||||
It("reloads a plugin when the wasm file is modified", func() {
|
||||
// First, load a plugin
|
||||
copyTestPlugin("watch-modify")
|
||||
|
||||
// Wait for it to be loaded
|
||||
Eventually(func() []string {
|
||||
return manager.PluginNames(string(CapabilityMetadataAgent))
|
||||
}, 1*time.Second, 50*time.Millisecond).Should(ContainElement("watch-modify"))
|
||||
|
||||
// Get the original plugin info
|
||||
originalInfo := manager.GetPluginInfo()["watch-modify"]
|
||||
Expect(originalInfo.Name).ToNot(BeEmpty())
|
||||
|
||||
// Modify the file (re-copy to trigger WRITE event)
|
||||
wasmPath := filepath.Join(tmpDir, "watch-modify.wasm")
|
||||
data, err := os.ReadFile(wasmPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Touch the file to trigger write event
|
||||
err = os.WriteFile(wasmPath, data, 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Wait for reload - the plugin should still be there
|
||||
// We can't easily verify it was reloaded without adding tracking,
|
||||
// but at least verify it's still loaded
|
||||
Consistently(func() []string {
|
||||
return manager.PluginNames(string(CapabilityMetadataAgent))
|
||||
}, 300*time.Millisecond, 50*time.Millisecond).Should(ContainElement("watch-modify"))
|
||||
})
|
||||
|
||||
It("unloads a plugin when the wasm file is removed", func() {
|
||||
// First, load a plugin
|
||||
wasmPath := copyTestPlugin("watch-remove")
|
||||
|
||||
// Wait for it to be loaded
|
||||
Eventually(func() []string {
|
||||
return manager.PluginNames(string(CapabilityMetadataAgent))
|
||||
}, 1*time.Second, 50*time.Millisecond).Should(ContainElement("watch-remove"))
|
||||
|
||||
// Remove the file
|
||||
err := os.Remove(wasmPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Wait for it to be unloaded
|
||||
Eventually(func() []string {
|
||||
return manager.PluginNames(string(CapabilityMetadataAgent))
|
||||
}, 1*time.Second, 50*time.Millisecond).ShouldNot(ContainElement("watch-remove"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Watcher disabled", func() {
|
||||
BeforeEach(func() {
|
||||
// Stop existing manager and create one without auto-reload
|
||||
_ = manager.Stop()
|
||||
|
||||
conf.Server.Plugins.AutoReload = false
|
||||
manager = &Manager{
|
||||
plugins: make(map[string]*pluginInstance),
|
||||
}
|
||||
err := manager.Start(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("does not auto-load plugins when AutoReload is disabled", func() {
|
||||
// Copy plugin file
|
||||
copyTestPlugin("no-watch")
|
||||
|
||||
// Wait a bit and verify plugin is NOT loaded
|
||||
Consistently(func() []string {
|
||||
return manager.PluginNames(string(CapabilityMetadataAgent))
|
||||
}, 300*time.Millisecond, 50*time.Millisecond).ShouldNot(ContainElement("no-watch"))
|
||||
It("returns actionNone for unknown event types", func() {
|
||||
// Event type 0 or other unknown values
|
||||
Expect(determinePluginAction(0, false)).To(Equal(actionNone))
|
||||
Expect(determinePluginAction(0, true)).To(Equal(actionNone))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user