2b1d0cf7d1
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
397 lines
14 KiB
Go
397 lines
14 KiB
Go
package backendapp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
goruntime "runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/plugins"
|
|
"github.com/kandev/kandev/internal/plugins/delivery"
|
|
"github.com/kandev/kandev/internal/plugins/pkgtar/pkgtartest"
|
|
"github.com/kandev/kandev/internal/plugins/store"
|
|
"github.com/kandev/kandev/internal/webapp"
|
|
"github.com/kandev/kandev/pkg/pluginsdk"
|
|
)
|
|
|
|
func testPluginsLogger(t *testing.T) *logger.Logger {
|
|
t.Helper()
|
|
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
|
|
if err != nil {
|
|
t.Fatalf("new logger: %v", err)
|
|
}
|
|
return log
|
|
}
|
|
|
|
// alwaysUpRuntime is a minimal plugins.PluginRuntime fake that always
|
|
// reports a successful spawn without touching a real subprocess, so
|
|
// Service.Install/Enable can reach StatusActive in tests that only exercise
|
|
// boot-payload/registry/status behavior.
|
|
type alwaysUpRuntime struct{}
|
|
|
|
func (alwaysUpRuntime) Start(context.Context, *store.Record, func(string) pluginsdk.Host) error {
|
|
return nil
|
|
}
|
|
func (alwaysUpRuntime) Stop(string) {}
|
|
func (alwaysUpRuntime) Get(string) (*pluginsdk.RemotePlugin, bool) { return nil, true }
|
|
func (alwaysUpRuntime) Ping(string) error { return nil }
|
|
func (alwaysUpRuntime) Running(string) bool { return true }
|
|
func (alwaysUpRuntime) RestartCount(string) int { return 0 }
|
|
func (alwaysUpRuntime) StopAll() {}
|
|
|
|
// testPluginPackage builds a valid, runtime-managed plugin tar.gz for the
|
|
// current host platform, with a capabilities.events subscription and
|
|
// (optionally) a UI bundle — mirroring internal/plugins/service_test.go's
|
|
// testPackage (kept separate here since that helper is unexported to that
|
|
// package).
|
|
func testPluginPackage(t *testing.T, id string, withUIBundle bool) *bytes.Buffer {
|
|
t.Helper()
|
|
platformKey := goruntime.GOOS + "-" + goruntime.GOARCH
|
|
manifestYAML := fmt.Sprintf(`
|
|
id: %s
|
|
api_version: 1
|
|
version: "1.0.0"
|
|
display_name: Test Plugin %s
|
|
capabilities:
|
|
events: ["task.created"]
|
|
runtime:
|
|
type: binary
|
|
executables:
|
|
%s: server/plugin
|
|
`, id, id, platformKey)
|
|
if withUIBundle {
|
|
manifestYAML += "ui:\n bundle: \"/ui/bundle.js\"\n styles: [\"/ui/style.css\"]\n"
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
files := map[string][]byte{
|
|
"manifest.yaml": []byte(manifestYAML),
|
|
"server/plugin": []byte("#!/bin/sh\necho fake\n"),
|
|
}
|
|
if withUIBundle {
|
|
files["ui/bundle.js"] = []byte("export default {};")
|
|
files["ui/style.css"] = []byte("body{}")
|
|
}
|
|
if err := pkgtartest.WritePackage(&buf, files); err != nil {
|
|
t.Fatalf("WritePackage: %v", err)
|
|
}
|
|
return &buf
|
|
}
|
|
|
|
// newTestPluginsService returns a plugins.Service backed by a temp-dir
|
|
// FSStore and an always-succeeding fake runtime, mirroring
|
|
// internal/plugins' own test helpers (kept separate here since those are
|
|
// unexported to that package). Install/Enable reach StatusActive without
|
|
// spawning a real subprocess.
|
|
func newTestPluginsService(t *testing.T) *plugins.Service {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
fsStore := store.NewFSStore(dir)
|
|
registry := plugins.NewRegistry()
|
|
if err := registry.Load(fsStore); err != nil {
|
|
t.Fatalf("load registry: %v", err)
|
|
}
|
|
svc := plugins.NewService(fsStore, registry, nil, testPluginsLogger(t))
|
|
if err := svc.SetPluginsDir(dir); err != nil {
|
|
t.Fatalf("SetPluginsDir: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = svc.Close() })
|
|
svc.SetRuntime(alwaysUpRuntime{})
|
|
return svc
|
|
}
|
|
|
|
func installTestPluginForBoot(t *testing.T, svc *plugins.Service, id string, withUIBundle bool) *store.Record {
|
|
t.Helper()
|
|
rec, err := svc.Install(context.Background(), testPluginPackage(t, id, withUIBundle))
|
|
if err != nil {
|
|
t.Fatalf("Install(%q): %v", id, err)
|
|
}
|
|
return rec
|
|
}
|
|
|
|
func testPluginPackageWithRepositoryProviders(t *testing.T, id string, providers []string) *bytes.Buffer {
|
|
t.Helper()
|
|
platformKey := goruntime.GOOS + "-" + goruntime.GOARCH
|
|
manifestYAML := fmt.Sprintf(`
|
|
id: %s
|
|
api_version: 1
|
|
version: "1.0.0"
|
|
display_name: Test Plugin %s
|
|
repository_providers: %s
|
|
runtime:
|
|
type: binary
|
|
executables:
|
|
%s: server/plugin
|
|
ui:
|
|
bundle: "/ui/bundle.js"
|
|
`, id, id, yamlStringList(providers), platformKey)
|
|
|
|
var buf bytes.Buffer
|
|
if err := pkgtartest.WritePackage(&buf, map[string][]byte{
|
|
"manifest.yaml": []byte(manifestYAML),
|
|
"server/plugin": []byte("#!/bin/sh\necho fake\n"),
|
|
"ui/bundle.js": []byte("export default {};"),
|
|
}); err != nil {
|
|
t.Fatalf("WritePackage: %v", err)
|
|
}
|
|
return &buf
|
|
}
|
|
|
|
func yamlStringList(values []string) string {
|
|
if len(values) == 0 {
|
|
return "[]"
|
|
}
|
|
quoted := make([]string, len(values))
|
|
for i, value := range values {
|
|
quoted[i] = fmt.Sprintf("%q", value)
|
|
}
|
|
return "[" + strings.Join(quoted, ", ") + "]"
|
|
}
|
|
|
|
// testPluginPackageVersioned mirrors testPluginPackage but lets the caller
|
|
// pin a specific manifest version, so tests can simulate an update (same id,
|
|
// bumped version) via a second Install call.
|
|
func testPluginPackageVersioned(t *testing.T, id, version string) *bytes.Buffer {
|
|
t.Helper()
|
|
platformKey := goruntime.GOOS + "-" + goruntime.GOARCH
|
|
manifestYAML := fmt.Sprintf(`
|
|
id: %s
|
|
api_version: 1
|
|
version: %q
|
|
display_name: Test Plugin %s
|
|
capabilities:
|
|
events: ["task.created"]
|
|
runtime:
|
|
type: binary
|
|
executables:
|
|
%s: server/plugin
|
|
ui:
|
|
bundle: "/ui/bundle.js"
|
|
styles: ["/ui/style.css"]
|
|
`, id, version, id, platformKey)
|
|
|
|
var buf bytes.Buffer
|
|
files := map[string][]byte{
|
|
"manifest.yaml": []byte(manifestYAML),
|
|
"server/plugin": []byte("#!/bin/sh\necho fake\n"),
|
|
"ui/bundle.js": []byte("export default {};"),
|
|
"ui/style.css": []byte("body{}"),
|
|
}
|
|
if err := pkgtartest.WritePackage(&buf, files); err != nil {
|
|
t.Fatalf("WritePackage: %v", err)
|
|
}
|
|
return &buf
|
|
}
|
|
|
|
// installTestPluginPackageForBoot installs (or, on a repeat call with a new
|
|
// version, updates) the plugin with an explicit version — used to prove
|
|
// bootActivePlugins keys BundleURL on the installed version.
|
|
func installTestPluginPackageForBoot(t *testing.T, svc *plugins.Service, id, version string) *store.Record {
|
|
t.Helper()
|
|
rec, err := svc.Install(context.Background(), testPluginPackageVersioned(t, id, version))
|
|
if err != nil {
|
|
t.Fatalf("Install(%q@%s): %v", id, version, err)
|
|
}
|
|
return rec
|
|
}
|
|
|
|
func TestBootActivePluginsPopulatesFromActiveUIPlugins(t *testing.T) {
|
|
svc := newTestPluginsService(t)
|
|
|
|
installTestPluginForBoot(t, svc, "kandev-plugin-hello", true)
|
|
|
|
// Disabled — must be excluded even though it declares a bundle.
|
|
installTestPluginForBoot(t, svc, "kandev-plugin-inactive", true)
|
|
if err := svc.Disable("kandev-plugin-inactive"); err != nil {
|
|
t.Fatalf("Disable(inactive): %v", err)
|
|
}
|
|
|
|
got := bootActivePlugins(routeParams{
|
|
services: &Services{Plugins: svc},
|
|
})
|
|
if len(got) != 1 {
|
|
t.Fatalf("bootActivePlugins() len = %d, want 1: %+v", len(got), got)
|
|
}
|
|
entry := got[0]
|
|
if entry.ID != "kandev-plugin-hello" {
|
|
t.Fatalf("entry.ID = %q, want %q", entry.ID, "kandev-plugin-hello")
|
|
}
|
|
if entry.BundleURL != "/api/plugins/kandev-plugin-hello/bundle?v=1.0.0" {
|
|
t.Fatalf("entry.BundleURL = %q, want %q", entry.BundleURL, "/api/plugins/kandev-plugin-hello/bundle?v=1.0.0")
|
|
}
|
|
if len(entry.StyleURLs) != 1 || entry.StyleURLs[0] != "/api/plugins/kandev-plugin-hello/ui/ui/style.css" {
|
|
t.Fatalf("entry.StyleURLs = %v, want [/api/plugins/kandev-plugin-hello/ui/ui/style.css]", entry.StyleURLs)
|
|
}
|
|
}
|
|
|
|
func TestBootActivePluginsProjectsRepositoryProviderIDs(t *testing.T) {
|
|
svc := newTestPluginsService(t)
|
|
if _, err := svc.Install(context.Background(), testPluginPackageWithRepositoryProviders(t, "kandev-plugin-bitbucket", []string{"bitbucket", "forge"})); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
if _, err := svc.Install(context.Background(), testPluginPackageWithRepositoryProviders(t, "kandev-plugin-no-provider", nil)); err != nil {
|
|
t.Fatalf("Install empty provider declaration: %v", err)
|
|
}
|
|
if _, err := svc.Install(context.Background(), testPluginPackage(t, "kandev-plugin-legacy-provider", true)); err != nil {
|
|
t.Fatalf("Install legacy provider declaration: %v", err)
|
|
}
|
|
|
|
entries := bootActivePlugins(routeParams{services: &Services{Plugins: svc}})
|
|
if len(entries) != 3 {
|
|
t.Fatalf("bootActivePlugins() len = %d, want 3: %+v", len(entries), entries)
|
|
}
|
|
byID := make(map[string]webapp.ActivePluginPayload, len(entries))
|
|
for _, entry := range entries {
|
|
byID[entry.ID] = entry
|
|
}
|
|
|
|
providerJSON, err := json.Marshal(byID["kandev-plugin-bitbucket"])
|
|
if err != nil {
|
|
t.Fatalf("marshal provider payload: %v", err)
|
|
}
|
|
var provider map[string]any
|
|
if err := json.Unmarshal(providerJSON, &provider); err != nil {
|
|
t.Fatalf("unmarshal provider payload: %v", err)
|
|
}
|
|
if got, ok := provider["repositoryProviderIds"]; !ok || fmt.Sprint(got) != "[bitbucket forge]" {
|
|
t.Fatalf("repositoryProviderIds = %v (present=%t), want [bitbucket forge]", got, ok)
|
|
}
|
|
|
|
emptyJSON, err := json.Marshal(byID["kandev-plugin-no-provider"])
|
|
if err != nil {
|
|
t.Fatalf("marshal empty provider payload: %v", err)
|
|
}
|
|
var empty map[string]any
|
|
if err := json.Unmarshal(emptyJSON, &empty); err != nil {
|
|
t.Fatalf("unmarshal empty provider payload: %v", err)
|
|
}
|
|
gotEmpty, present := empty["repositoryProviderIds"]
|
|
if !present || fmt.Sprint(gotEmpty) != "[]" {
|
|
t.Fatalf("repositoryProviderIds = %v (present=%t), want []", gotEmpty, present)
|
|
}
|
|
|
|
legacyJSON, err := json.Marshal(byID["kandev-plugin-legacy-provider"])
|
|
if err != nil {
|
|
t.Fatalf("marshal legacy payload: %v", err)
|
|
}
|
|
var legacy map[string]any
|
|
if err := json.Unmarshal(legacyJSON, &legacy); err != nil {
|
|
t.Fatalf("unmarshal legacy payload: %v", err)
|
|
}
|
|
if _, present := legacy["repositoryProviderIds"]; present {
|
|
t.Fatalf("legacy repositoryProviderIds must be omitted: %s", legacyJSON)
|
|
}
|
|
}
|
|
|
|
// TestBootActivePluginsBundleURLChangesWithVersion proves the fix for the
|
|
// same-tab plugin-update bug: unloadPlugin(id, {evictCache:true}) evicts the
|
|
// cached bundle registration on update, but a same-tab re-import() of the
|
|
// same URL returns the browser's already-evaluated ES module without
|
|
// re-running its top-level registerKandevPlugin() call — leaving the plugin
|
|
// active but unregistered. Keying BundleURL on the installed version forces
|
|
// a real re-import (new module specifier) whenever the version changes,
|
|
// while keeping an unchanged version's URL byte-identical across boots.
|
|
func TestBootActivePluginsBundleURLChangesWithVersion(t *testing.T) {
|
|
dir := t.TempDir()
|
|
fsStore := store.NewFSStore(dir)
|
|
registry := plugins.NewRegistry()
|
|
if err := registry.Load(fsStore); err != nil {
|
|
t.Fatalf("load registry: %v", err)
|
|
}
|
|
svc := plugins.NewService(fsStore, registry, nil, testPluginsLogger(t))
|
|
svc.SetPluginsDir(dir)
|
|
svc.SetRuntime(alwaysUpRuntime{})
|
|
|
|
installTestPluginPackageForBoot(t, svc, "kandev-plugin-hello", "1.0.0")
|
|
first := bootActivePlugins(routeParams{
|
|
services: &Services{Plugins: svc},
|
|
})
|
|
if len(first) != 1 {
|
|
t.Fatalf("bootActivePlugins() len = %d, want 1: %+v", len(first), first)
|
|
}
|
|
firstURL := first[0].BundleURL
|
|
if firstURL != "/api/plugins/kandev-plugin-hello/bundle?v=1.0.0" {
|
|
t.Fatalf("first BundleURL = %q, want %q", firstURL, "/api/plugins/kandev-plugin-hello/bundle?v=1.0.0")
|
|
}
|
|
|
|
// Same version reinstalled (no-op update) must resolve to the identical
|
|
// URL — no needless cache-busting.
|
|
repeat := bootActivePlugins(routeParams{
|
|
services: &Services{Plugins: svc},
|
|
})
|
|
if repeat[0].BundleURL != firstURL {
|
|
t.Fatalf("repeat BundleURL = %q, want unchanged %q", repeat[0].BundleURL, firstURL)
|
|
}
|
|
|
|
// Update to a new version: the URL must differ so the browser re-imports
|
|
// and re-executes the bundle's registerKandevPlugin() side effect.
|
|
installTestPluginPackageForBoot(t, svc, "kandev-plugin-hello", "1.0.1")
|
|
updated := bootActivePlugins(routeParams{
|
|
services: &Services{Plugins: svc},
|
|
})
|
|
updatedURL := updated[0].BundleURL
|
|
if updatedURL == firstURL {
|
|
t.Fatalf("updated BundleURL = %q, want different from %q after a version bump", updatedURL, firstURL)
|
|
}
|
|
if updatedURL != "/api/plugins/kandev-plugin-hello/bundle?v=1.0.1" {
|
|
t.Fatalf("updated BundleURL = %q, want %q", updatedURL, "/api/plugins/kandev-plugin-hello/bundle?v=1.0.1")
|
|
}
|
|
}
|
|
|
|
// TestBootActivePluginsNoServiceReturnsNil covers one reason the boot payload
|
|
// carries no plugins now that they ship unflagged: the plugin service is absent
|
|
// because initialization failed. An initialized service with nothing active
|
|
// that declares a UI bundle yields an empty payload too — see
|
|
// TestBootActivePluginsPopulatesFromActiveUIPlugins for that path.
|
|
func TestBootActivePluginsNoServiceReturnsNil(t *testing.T) {
|
|
if got := bootActivePlugins(routeParams{services: &Services{}}); got != nil {
|
|
t.Fatalf("bootActivePlugins() with nil Plugins service = %v, want nil", got)
|
|
}
|
|
if got := bootActivePlugins(routeParams{}); got != nil {
|
|
t.Fatalf("bootActivePlugins() with nil services = %v, want nil", got)
|
|
}
|
|
}
|
|
|
|
// --- pluginActivePluginsAdapter ---
|
|
|
|
func TestPluginActivePluginsAdapterIncludesActiveAndErrorOnly(t *testing.T) {
|
|
svc := newTestPluginsService(t)
|
|
|
|
installTestPluginForBoot(t, svc, "kandev-plugin-active", false) // active after install
|
|
|
|
installTestPluginForBoot(t, svc, "kandev-plugin-error", false)
|
|
if err := svc.SetStatus("kandev-plugin-error", plugins.StatusError); err != nil {
|
|
t.Fatalf("SetStatus(error): %v", err)
|
|
}
|
|
|
|
installTestPluginForBoot(t, svc, "kandev-plugin-disabled", false)
|
|
if err := svc.Disable("kandev-plugin-disabled"); err != nil {
|
|
t.Fatalf("Disable(disabled): %v", err)
|
|
}
|
|
|
|
adapter := pluginActivePluginsAdapter{svc: svc}
|
|
records := adapter.ActivePlugins()
|
|
|
|
byID := make(map[string]delivery.PluginRecord, len(records))
|
|
for _, rec := range records {
|
|
byID[rec.ID] = rec
|
|
}
|
|
if len(byID) != 2 {
|
|
t.Fatalf("ActivePlugins() len = %d, want 2: %+v", len(byID), records)
|
|
}
|
|
if _, ok := byID["kandev-plugin-disabled"]; ok {
|
|
t.Fatal("ActivePlugins() must not include a StatusDisabled plugin")
|
|
}
|
|
rec, ok := byID["kandev-plugin-active"]
|
|
if !ok {
|
|
t.Fatal("ActivePlugins() missing kandev-plugin-active")
|
|
}
|
|
if len(rec.EventSubjects) != 1 || rec.EventSubjects[0] != "task.created" {
|
|
t.Fatalf("ActivePlugins() record = %+v, want EventSubjects=[task.created]", rec)
|
|
}
|
|
}
|