tests(plugins): optimize tests

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan
2025-12-22 12:40:38 -05:00
parent bc0a4fe256
commit 55ed0f6ed0
10 changed files with 268 additions and 245 deletions

View File

@@ -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() {
}
}
})
})

View File

@@ -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"))
})
})

View File

@@ -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
View 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 ../$@ .

View File

@@ -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&notify.Remove != 0 || eventType&notify.Rename != 0:
// File removed or renamed away - unload if loaded
return actionUnload
case eventType&notify.Create != 0:
// New file - load it
return actionLoad
case eventType&notify.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&notify.Remove != 0 || eventType&notify.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&notify.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&notify.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)
}
}
}

View 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())
})
})
})

View File

@@ -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))
})
})
})