navidrome/plugins/plugins_suite_test.go
Deluan Quintão 46041bb908
ci: cache the plugins test suite WASM compilation across runs (#6049)
* ci: cache the plugins test suite WASM compilation across runs

The 'Test Go code' job was dominated by a single package: 'plugins' took
541s of the 699s test step. The suite builds 25 test plugins as full-Go
wasip1 modules of ~4.5MB each, and wazero must compile every one to machine
code. Under -race that compiler work is instrumented, so each module costs
around 11 seconds.

The suite already shared a wazero compilation cache, but three things kept it
from paying off. It lived in a fresh temp dir, so nothing survived the run.
The default plugins.cachesize of 200MB was smaller than the 334MB the cache
actually needs, so the purge evicted entries mid-run. And the wasm binaries
embedded VCS stamps, so every commit produced different bytes and missed the
content-addressed cache anyway.

Point CacheFolder at plugins/testdata/.wazero-cache, raise the test cache
limit past what the suite needs, build the test plugins with -buildvcs=false,
and restore the directory in CI. Locally the package goes from 256s to 74s
with the cache warm and the wasm rebuilt from scratch.

* ci: key the WASM cache on what actually changes the modules

The test plugins are separate Go modules with their own go.mod and go.sum;
they reach the PDK through a replace directive and never read the root
module. So the root go.sum has no bearing on the wasm bytes, and the wazero
version it pins is already namespaced by wazero itself, which stores entries
under wazero-<version>-<goarch>-<goos>. Keying on it only rotated the cache
on every unrelated dependency bump.

Drop it, and add the go.mod files that were missing: the test plugins' own
and the PDK's. The root go.mod stays, since it selects the toolchain that
builds the modules.

* ci: key the WASM cache on the toolchain version, not go.mod

Only the Go toolchain in the root go.mod affects the built wasm, but the file
also changes on every direct dependency bump, which would rotate the cache for
no reason. Take setup-go's go-version output instead: it is the version that
actually built the modules.
2026-08-30 16:06:08 -04:00

171 lines
5.6 KiB
Go

//go:build !windows
package plugins
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
const (
testDataDir = "plugins/testdata"
wazeroCacheDir = ".wazero-cache"
)
// Shared test state initialized in BeforeSuite
var (
testdataDir string // Path to testdata folder with test plugin .ndp packages
tmpPluginsDir string // Temp directory for plugin tests that modify files
testManager *Manager
)
func TestPlugins(t *testing.T) {
tests.Init(t, false)
buildTestPlugins(t, testDataDir)
// Set globally so tests using configtest.SetupConfig inherit it. The cache
// persists between runs; entries are content-addressed, so a stale one only misses.
conf.Server.CacheFolder = conf.NewDir(filepath.Join(testDataDir, wazeroCacheDir))
conf.Server.Plugins.CacheSize = "1GB" // the default evicts the cache mid-run
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Plugins Suite")
}
func buildTestPlugins(t *testing.T, path string) {
t.Helper()
start := time.Now()
t.Logf("[BeforeSuite] Current working directory: %s", path)
cmd := exec.Command("make", "-C", path)
out, err := cmd.CombinedOutput()
t.Logf("[BeforeSuite] Make output: %s elapsed: %s", string(out), time.Since(start))
if err != nil {
t.Fatalf("Failed to build test plugins: %v", err)
}
}
// createTestManager creates a new plugin Manager with the given plugin config.
// It creates a temp directory, copies the test-metadata-agent plugin, and starts the manager.
// Returns the manager, temp directory path, and a cleanup function.
func createTestManager(pluginConfig map[string]map[string]string) (*Manager, string) {
return createTestManagerWithPlugins(pluginConfig, "test-metadata-agent"+PackageExtension)
}
// createTestManagerWithPlugins creates a new plugin Manager with the given plugin config
// and specified plugins. It creates a temp directory, copies the specified plugins, and starts the manager.
// Returns the manager and temp directory path.
func createTestManagerWithPlugins(pluginConfig map[string]map[string]string, plugins ...string) (*Manager, string) {
return createTestManagerWithPluginsAndMetrics(pluginConfig, noopMetricsRecorder{}, plugins...)
}
// installTestPlugins copies the given .ndp packages into dir and returns their
// enabled DB rows, so callers can grant whatever access the test needs.
func installTestPlugins(dir string, plugins ...string) model.Plugins {
var rows model.Plugins
for _, plugin := range plugins {
data, err := os.ReadFile(filepath.Join(testdataDir, plugin))
Expect(err).ToNot(HaveOccurred())
destPath := filepath.Join(dir, plugin)
Expect(os.WriteFile(destPath, data, 0600)).To(Succeed())
hash := sha256.Sum256(data)
rows = append(rows, model.Plugin{
ID: strings.TrimSuffix(plugin, PackageExtension),
Path: destPath,
SHA256: hex.EncodeToString(hash[:]),
Enabled: true,
})
}
return rows
}
// createTestManagerWithPluginsAndMetrics creates a new plugin Manager with the given plugin config,
// metrics recorder, and specified plugins. It creates a temp directory, copies the specified plugins,
// and starts the manager. Returns the manager and temp directory path.
func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]string, metrics PluginMetricsRecorder, plugins ...string) (*Manager, string) {
// Create temp directory
tmpDir, err := os.MkdirTemp("", "plugins-test-*")
Expect(err).ToNot(HaveOccurred())
enabledPlugins := installTestPlugins(tmpDir, plugins...)
for i, p := range enabledPlugins {
enabledPlugins[i].AllUsers = true // Allow all users by default in tests
if pluginConfig[p.ID] != nil {
configBytes, err := json.Marshal(pluginConfig[p.ID])
Expect(err).ToNot(HaveOccurred())
enabledPlugins[i].Config = string(configBytes)
}
}
// Setup config
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
conf.Server.Plugins.AutoReload = false
// Setup mock DataStore with pre-enabled plugins
mockPluginRepo := tests.CreateMockPluginRepo()
mockPluginRepo.Permitted = true
mockPluginRepo.SetData(enabledPlugins)
dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo}
// Create and start manager
manager := &Manager{
plugins: make(map[string]*plugin),
ds: dataStore,
metrics: metrics,
subsonicRouter: http.NotFoundHandler(), // Stub router for tests
}
err = manager.Start(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = manager.Stop()
_ = os.RemoveAll(tmpDir)
})
return manager, tmpDir
}
var _ = BeforeSuite(func() {
// Get testdata directory (where test plugin .ndp packages live)
_, currentFile, _, ok := runtime.Caller(0)
Expect(ok).To(BeTrue())
testdataDir = filepath.Join(filepath.Dir(currentFile), "testdata")
// Create shared manager for most tests
testManager, tmpPluginsDir = createTestManager(nil)
})
var _ = AfterSuite(func() {
if testManager != nil {
_ = testManager.Stop()
}
if tmpPluginsDir != "" {
_ = os.RemoveAll(tmpPluginsDir)
}
})
// noopMetricsRecorder is a no-op implementation of PluginMetricsRecorder for tests
type noopMetricsRecorder struct{}
func (noopMetricsRecorder) RecordPluginRequest(context.Context, string, string, bool, int64) {}