mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
tests(plugins): optimize tests
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
39be1878cb
commit
f0f191266c
@ -3,7 +3,6 @@ package plugins
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
@ -15,65 +14,43 @@ import (
|
|||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ = Describe("Manager", func() {
|
var _ = Describe("Manager", Ordered, func() {
|
||||||
var (
|
var (
|
||||||
manager *Manager
|
manager *Manager
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
testdataDir string
|
testdataDir string
|
||||||
tmpDir string
|
|
||||||
)
|
)
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeAll(func() {
|
||||||
ctx = GinkgoT().Context()
|
ctx = context.Background()
|
||||||
|
|
||||||
// Get testdata directory
|
// Get testdata directory (where fake-metadata-agent.wasm lives)
|
||||||
_, currentFile, _, ok := runtime.Caller(0)
|
_, currentFile, _, ok := runtime.Caller(0)
|
||||||
Expect(ok).To(BeTrue())
|
Expect(ok).To(BeTrue())
|
||||||
testdataDir = filepath.Join(filepath.Dir(currentFile), "testdata")
|
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
|
// Setup config
|
||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
conf.Server.Plugins.Enabled = true
|
conf.Server.Plugins.Enabled = true
|
||||||
conf.Server.Plugins.Folder = tmpDir
|
conf.Server.Plugins.Folder = testdataDir
|
||||||
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
|
||||||
|
|
||||||
// Create a fresh manager for each test
|
// Create manager once for all tests
|
||||||
manager = &Manager{
|
manager = &Manager{
|
||||||
plugins: make(map[string]*pluginInstance),
|
plugins: make(map[string]*pluginInstance),
|
||||||
}
|
}
|
||||||
err = manager.Start(ctx)
|
err := manager.Start(ctx)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
DeferCleanup(func() {
|
DeferCleanup(func() {
|
||||||
_ = manager.Stop()
|
_ = 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() {
|
Describe("LoadPlugin", func() {
|
||||||
It("loads a new plugin by name", func() {
|
It("auto-loads plugins from folder on Start", func() {
|
||||||
copyTestPlugin("new-plugin")
|
// Plugin is already loaded by manager.Start() via discoverPlugins
|
||||||
|
|
||||||
err := manager.LoadPlugin("new-plugin")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
names := manager.PluginNames(string(CapabilityMetadataAgent))
|
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() {
|
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() {
|
It("returns error when plugin is already loaded", func() {
|
||||||
copyTestPlugin("duplicate")
|
// Plugin was loaded on Start, try to load again
|
||||||
|
err := manager.LoadPlugin("fake-metadata-agent")
|
||||||
err := manager.LoadPlugin("duplicate")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
err = manager.LoadPlugin("duplicate")
|
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
Expect(err.Error()).To(ContainSubstring("already loaded"))
|
Expect(err.Error()).To(ContainSubstring("already loaded"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns error when plugins folder is not configured", func() {
|
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.Plugins.Folder = ""
|
||||||
conf.Server.DataFolder = ""
|
conf.Server.DataFolder = ""
|
||||||
|
defer func() {
|
||||||
|
conf.Server.Plugins.Folder = originalFolder
|
||||||
|
conf.Server.DataFolder = originalDataFolder
|
||||||
|
}()
|
||||||
|
|
||||||
err := manager.LoadPlugin("test")
|
err := manager.LoadPlugin("test")
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
@ -105,15 +84,21 @@ var _ = Describe("Manager", func() {
|
|||||||
|
|
||||||
Describe("UnloadPlugin", func() {
|
Describe("UnloadPlugin", func() {
|
||||||
It("removes a loaded plugin", func() {
|
It("removes a loaded plugin", func() {
|
||||||
copyTestPlugin("to-unload")
|
// Plugin is already loaded from Start
|
||||||
err := manager.LoadPlugin("to-unload")
|
err := manager.UnloadPlugin("fake-metadata-agent")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
err = manager.UnloadPlugin("to-unload")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
names := manager.PluginNames(string(CapabilityMetadataAgent))
|
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() {
|
It("returns error when plugin not found", func() {
|
||||||
@ -125,15 +110,11 @@ var _ = Describe("Manager", func() {
|
|||||||
|
|
||||||
Describe("ReloadPlugin", func() {
|
Describe("ReloadPlugin", func() {
|
||||||
It("unloads and reloads a plugin", func() {
|
It("unloads and reloads a plugin", func() {
|
||||||
copyTestPlugin("to-reload")
|
err := manager.ReloadPlugin("fake-metadata-agent")
|
||||||
err := manager.LoadPlugin("to-reload")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
err = manager.ReloadPlugin("to-reload")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
names := manager.PluginNames(string(CapabilityMetadataAgent))
|
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() {
|
It("returns error when plugin not found", func() {
|
||||||
@ -141,31 +122,10 @@ var _ = Describe("Manager", func() {
|
|||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
Expect(err.Error()).To(ContainSubstring("failed to unload"))
|
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() {
|
It("can call the plugin concurrently", func() {
|
||||||
copyTestPlugin("new-plugin")
|
// Plugin is already loaded
|
||||||
err := manager.LoadPlugin("new-plugin")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
const concurrency = 100
|
const concurrency = 100
|
||||||
errs := make(chan error, concurrency)
|
errs := make(chan error, concurrency)
|
||||||
@ -176,7 +136,7 @@ var _ = Describe("Manager", func() {
|
|||||||
for i := range concurrency {
|
for i := range concurrency {
|
||||||
go func(i int) {
|
go func(i int) {
|
||||||
defer g.Done()
|
defer g.Done()
|
||||||
a, ok := manager.LoadMediaAgent("new-plugin")
|
a, ok := manager.LoadMediaAgent("fake-metadata-agent")
|
||||||
Expect(ok).To(BeTrue())
|
Expect(ok).To(BeTrue())
|
||||||
agent := a.(agents.ArtistBiographyRetriever)
|
agent := a.(agents.ArtistBiographyRetriever)
|
||||||
bio, err := agent.GetArtistBiography(ctx, fmt.Sprintf("artist-%d", i), fmt.Sprintf("Artist %d", i), "")
|
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())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
// Copy test plugin to temp dir
|
// Copy test plugin to temp dir
|
||||||
srcPath := filepath.Join(testdataDir, "test-plugin.wasm")
|
srcPath := filepath.Join(testdataDir, "fake-metadata-agent.wasm")
|
||||||
destPath := filepath.Join(tmpDir, "test-plugin.wasm")
|
destPath := filepath.Join(tmpDir, "fake-metadata-agent.wasm")
|
||||||
data, err := os.ReadFile(srcPath)
|
data, err := os.ReadFile(srcPath)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
err = os.WriteFile(destPath, data, 0600)
|
err = os.WriteFile(destPath, data, 0600)
|
||||||
@ -58,7 +58,7 @@ var _ = Describe("MetadataAgent", Ordered, func() {
|
|||||||
|
|
||||||
// Load the agent via manager
|
// Load the agent via manager
|
||||||
var ok2 bool
|
var ok2 bool
|
||||||
agent, ok2 = manager.LoadMediaAgent("test-plugin")
|
agent, ok2 = manager.LoadMediaAgent("fake-metadata-agent")
|
||||||
Expect(ok2).To(BeTrue())
|
Expect(ok2).To(BeTrue())
|
||||||
|
|
||||||
DeferCleanup(func() {
|
DeferCleanup(func() {
|
||||||
@ -69,7 +69,7 @@ var _ = Describe("MetadataAgent", Ordered, func() {
|
|||||||
|
|
||||||
Describe("AgentName", func() {
|
Describe("AgentName", func() {
|
||||||
It("returns the plugin name", 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
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os/exec"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const testDataDir = "plugins/testdata"
|
||||||
|
|
||||||
func TestPlugins(t *testing.T) {
|
func TestPlugins(t *testing.T) {
|
||||||
|
tests.Init(t, false)
|
||||||
|
buildTestPlugins(t, testDataDir)
|
||||||
|
log.SetLevel(log.LevelFatal)
|
||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
RunSpecs(t, "Plugins Suite")
|
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()
|
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
|
// processPluginEvent handles the actual plugin load/unload/reload after debouncing
|
||||||
func (m *Manager) processPluginEvent(pluginName string, eventType notify.Event) {
|
func (m *Manager) processPluginEvent(pluginName string, eventType notify.Event) {
|
||||||
// Don't process if manager is stopping/stopped (atomic check to avoid race with Stop())
|
// 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)
|
delete(m.debounceTimers, pluginName)
|
||||||
m.debounceMu.Unlock()
|
m.debounceMu.Unlock()
|
||||||
|
|
||||||
switch {
|
// Check if plugin is currently loaded
|
||||||
case eventType¬ify.Remove != 0 || eventType¬ify.Rename != 0:
|
m.mu.RLock()
|
||||||
// File removed or renamed away - unload if loaded
|
_, 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 {
|
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)
|
log.Debug(m.ctx, "Plugin not loaded, skipping unload", "plugin", pluginName, err)
|
||||||
}
|
}
|
||||||
|
case actionReload:
|
||||||
case eventType¬ify.Create != 0:
|
if err := m.ReloadPlugin(pluginName); err != nil {
|
||||||
// New file - load it
|
log.Error(m.ctx, "Failed to reload plugin", "plugin", pluginName, err)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
package plugins
|
||||||
|
|
||||||
import (
|
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/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
|
"github.com/rjeczalik/notify"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ = Describe("Watcher", func() {
|
var _ = Describe("Watcher", func() {
|
||||||
var (
|
Describe("determinePluginAction", func() {
|
||||||
manager *Manager
|
// These are fast unit tests for the pure routing logic.
|
||||||
ctx context.Context
|
// No WASM compilation, no file I/O - runs in microseconds.
|
||||||
testdataDir string
|
|
||||||
tmpDir string
|
|
||||||
)
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
DescribeTable("returns correct action for event type and loaded state",
|
||||||
ctx = GinkgoT().Context()
|
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
|
// WRITE events - reload if loaded, load if not
|
||||||
originalDebounce := debounceDuration
|
Entry("WRITE when not loaded", notify.Write, false, actionLoad),
|
||||||
debounceDuration = 50 * time.Millisecond
|
Entry("WRITE when loaded", notify.Write, true, actionReload),
|
||||||
DeferCleanup(func() { debounceDuration = originalDebounce })
|
|
||||||
|
|
||||||
// Get testdata directory
|
// REMOVE events - always unload
|
||||||
_, currentFile, _, ok := runtime.Caller(0)
|
Entry("REMOVE when not loaded", notify.Remove, false, actionUnload),
|
||||||
Expect(ok).To(BeTrue())
|
Entry("REMOVE when loaded", notify.Remove, true, actionUnload),
|
||||||
testdataDir = filepath.Join(filepath.Dir(currentFile), "testdata")
|
|
||||||
|
|
||||||
// Create temp dir for plugins
|
// RENAME events - treated same as REMOVE
|
||||||
var err error
|
Entry("RENAME when not loaded", notify.Rename, false, actionUnload),
|
||||||
tmpDir, err = os.MkdirTemp("", "plugins-watcher-test-*")
|
Entry("RENAME when loaded", notify.Rename, true, actionUnload),
|
||||||
Expect(err).ToNot(HaveOccurred())
|
)
|
||||||
|
|
||||||
// Setup config with AutoReload enabled
|
It("returns actionNone for unknown event types", func() {
|
||||||
DeferCleanup(configtest.SetupConfig())
|
// Event type 0 or other unknown values
|
||||||
conf.Server.Plugins.Enabled = true
|
Expect(determinePluginAction(0, false)).To(Equal(actionNone))
|
||||||
conf.Server.Plugins.Folder = tmpDir
|
Expect(determinePluginAction(0, true)).To(Equal(actionNone))
|
||||||
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"))
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user