hotfix: plugin manager infinite loading

- frontend: RPC timeout (8s), try/catch/finally on reload, proper UI
  states (loading/error/empty/list)
- frontend: reload() now sets loading=true, catches errors
- backend: tilde expansion (~/.config/verstak/plugins → /home/mirivlad/...)
- backend: ReloadPlugins returns diagnostics (count, summary string)
- backend: diagnostic logging in DiscoverPlugins (start/dirs/entries/results)
- backend: FormatDiscoverySummary helper
- testing: 11 headless tests for DiscoverPlugins (empty, missing, valid,
  broken JSON, duplicate ID, multiple dirs, nonexistent mix)
This commit is contained in:
2026-06-16 13:52:49 +08:00
parent e39e249556
commit 3c613f0e44
7 changed files with 406 additions and 14 deletions
+25
View File
@@ -4,6 +4,7 @@ package plugin
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
@@ -231,6 +232,18 @@ func isAllowedInID(r rune) bool {
// ─── Discovery ──────────────────────────────────────────────
// FormatDiscoverySummary returns a human-readable summary of discovered plugins.
func FormatDiscoverySummary(plugins []Plugin) string {
if len(plugins) == 0 {
return "no plugins found"
}
ids := make([]string, 0, len(plugins))
for _, p := range plugins {
ids = append(ids, p.Manifest.ID+"@"+p.Manifest.Version)
}
return fmt.Sprintf("%d plugin(s): %s", len(plugins), strings.Join(ids, ", "))
}
// DiscoverPlugins scans the given directories for plugin.json manifests.
func DiscoverPlugins(dirs []string) ([]Plugin, []error) {
var plugins []Plugin
@@ -238,16 +251,22 @@ func DiscoverPlugins(dirs []string) ([]Plugin, []error) {
seen := make(map[string]bool)
log.Printf("[discovery] start: %d dir(s): %v", len(dirs), dirs)
for _, dir := range dirs {
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
log.Printf("[discovery] dir %q: does not exist (skip)", dir)
continue
}
if err != nil {
errs = append(errs, fmt.Errorf("reading plugin directory %s: %w", dir, err))
log.Printf("[discovery] dir %q: error: %v", dir, err)
continue
}
log.Printf("[discovery] dir %q: %d entries", dir, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
@@ -257,24 +276,30 @@ func DiscoverPlugins(dirs []string) ([]Plugin, []error) {
manifestPath := filepath.Join(pluginDir, "plugin.json")
if _, err := os.Stat(manifestPath); os.IsNotExist(err) {
log.Printf("[discovery] %s: no plugin.json (skip)", entry.Name())
continue
}
plugin, err := loadPlugin(pluginDir)
if err != nil {
errs = append(errs, fmt.Errorf("plugin %s: %w", entry.Name(), err))
log.Printf("[discovery] %s: load error: %v", entry.Name(), err)
continue
}
if seen[plugin.Manifest.ID] {
errs = append(errs, fmt.Errorf("duplicate plugin ID %q in %s", plugin.Manifest.ID, pluginDir))
log.Printf("[discovery] %s: duplicate ID %q (skip)", entry.Name(), plugin.Manifest.ID)
continue
}
seen[plugin.Manifest.ID] = true
plugins = append(plugins, plugin)
log.Printf("[discovery] %s: ✅ %s@%s", entry.Name(), plugin.Manifest.ID, plugin.Manifest.Version)
}
}
log.Printf("[discovery] end: %d plugin(s) found, %d error(s)", len(plugins), len(errs))
return plugins, errs
}
+212
View File
@@ -0,0 +1,212 @@
package plugin
import (
"os"
"path/filepath"
"strings"
"testing"
)
// createTempPlugin creates a minimal valid plugin directory for testing.
func createTempPlugin(t *testing.T, dir, id, name string) string {
t.Helper()
pluginDir := filepath.Join(dir, id)
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
manifest := `{
"schemaVersion": 1,
"id": "` + id + `",
"name": "` + name + `",
"version": "1.0.0",
"apiVersion": "1.0",
"provides": ["` + id + `.cap1"],
"permissions": ["vault.read"]
}`
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifest), 0644); err != nil {
t.Fatal(err)
}
return pluginDir
}
// TestDiscoverPlugins_EmptyDir tests discovery on a directory that does not exist.
// It should return empty results, not hang or error out.
func TestDiscoverPlugins_EmptyDir(t *testing.T) {
plugins, errs := DiscoverPlugins([]string{"/tmp/nonexistent-plugin-dir-12345"})
if len(plugins) != 0 {
t.Errorf("expected 0 plugins, got %d", len(plugins))
}
if len(errs) != 0 {
t.Errorf("expected 0 errors, got %d: %v", len(errs), errs)
}
}
// TestDiscoverPlugins_MissingDir tests discovery where the directory does not exist.
// It should not be treated as an error — just skip.
func TestDiscoverPlugins_MissingDir(t *testing.T) {
plugins, errs := DiscoverPlugins([]string{"/tmp/missing-dir-99999"})
if len(plugins) != 0 {
t.Errorf("expected 0 plugins, got %d", len(plugins))
}
if len(errs) != 0 {
t.Errorf("expected 0 errors for missing dir, got %d", len(errs))
}
}
// TestDiscoverPlugins_ValidPlugin tests discovery of a single valid plugin.
func TestDiscoverPlugins_ValidPlugin(t *testing.T) {
dir := t.TempDir()
createTempPlugin(t, dir, "test.plugin", "Test Plugin")
plugins, errs := DiscoverPlugins([]string{dir})
if len(plugins) != 1 {
t.Fatalf("expected 1 plugin, got %d", len(plugins))
}
if plugins[0].Manifest.ID != "test.plugin" {
t.Errorf("expected id 'test.plugin', got %q", plugins[0].Manifest.ID)
}
if !plugins[0].Enabled {
t.Error("expected plugin to be enabled by default")
}
if len(errs) > 0 {
t.Errorf("expected 0 errors, got %d: %v", len(errs), errs)
}
}
// TestDiscoverPlugins_NoManifest ensures subdirs without plugin.json are skipped.
func TestDiscoverPlugins_NoManifest(t *testing.T) {
dir := t.TempDir()
noManifestDir := filepath.Join(dir, "no-manifest")
if err := os.MkdirAll(noManifestDir, 0755); err != nil {
t.Fatal(err)
}
plugins, errs := DiscoverPlugins([]string{dir})
if len(plugins) != 0 {
t.Errorf("expected 0 plugins (no manifest), got %d", len(plugins))
}
if len(errs) != 0 {
t.Errorf("expected 0 errors, got %d", len(errs))
}
}
// TestDiscoverPlugins_BrokenJSON ensures a corrupted manifest is reported as error
// but does not crash or hang discovery.
func TestDiscoverPlugins_BrokenJSON(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "broken")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte("{invalid json}"), 0644); err != nil {
t.Fatal(err)
}
plugins, errs := DiscoverPlugins([]string{dir})
if len(plugins) != 0 {
t.Errorf("expected 0 plugins (broken manifest), got %d", len(plugins))
}
if len(errs) == 0 {
t.Error("expected at least 1 error for broken manifest, got 0")
}
}
// TestDiscoverPlugins_DuplicateID ensures duplicate plugin IDs are detected.
func TestDiscoverPlugins_DuplicateID(t *testing.T) {
dir := t.TempDir()
createTempPlugin(t, dir, "dup-one", "First")
// Create a second plugin with the same ID (duplicate)
pluginDir2 := filepath.Join(dir, "dup-two")
if err := os.MkdirAll(pluginDir2, 0755); err != nil {
t.Fatal(err)
}
manifest := `{
"schemaVersion": 1,
"id": "dup-one",
"name": "Second",
"version": "1.0.0",
"apiVersion": "1.0",
"provides": ["dup-one.cap1"],
"permissions": ["vault.read"]
}`
if err := os.WriteFile(filepath.Join(pluginDir2, "plugin.json"), []byte(manifest), 0644); err != nil {
t.Fatal(err)
}
plugins, errs := DiscoverPlugins([]string{dir})
if len(plugins) != 1 {
t.Errorf("expected 1 plugin (deduped), got %d", len(plugins))
}
hasDupError := false
for _, e := range errs {
if strings.Contains(e.Error(), "duplicate") {
hasDupError = true
}
}
if !hasDupError {
t.Error("expected duplicate plugin ID error")
}
}
// TestDiscoverPlugins_MultipleDirs ensures discovery scans multiple directories.
func TestDiscoverPlugins_MultipleDirs(t *testing.T) {
dir1 := t.TempDir()
dir2 := t.TempDir()
createTempPlugin(t, dir1, "alpha.plugin", "Alpha")
createTempPlugin(t, dir2, "beta.plugin", "Beta")
plugins, errs := DiscoverPlugins([]string{dir1, dir2})
if len(plugins) != 2 {
t.Fatalf("expected 2 plugins, got %d", len(plugins))
}
if len(errs) > 0 {
t.Errorf("expected 0 errors, got %d: %v", len(errs), errs)
}
}
// TestDiscoverPlugins_AllowsNonexistentDirs ensures that a mix of valid and
// nonexistent directories doesn't cause issues.
func TestDiscoverPlugins_AllowsNonexistentDirs(t *testing.T) {
dir := t.TempDir()
createTempPlugin(t, dir, "survivor.plugin", "Survivor")
plugins, errs := DiscoverPlugins([]string{
"/tmp/missing-aaa-88888",
dir,
"/tmp/missing-bbb-99999",
})
if len(plugins) != 1 {
t.Fatalf("expected 1 plugin, got %d", len(plugins))
}
if plugins[0].Manifest.ID != "survivor.plugin" {
t.Errorf("expected 'survivor.plugin', got %q", plugins[0].Manifest.ID)
}
if len(errs) > 0 {
t.Errorf("expected 0 errors, got %d: %v", len(errs), errs)
}
}
// TestFormatDiscoverySummary on empty list.
func TestFormatDiscoverySummary_Empty(t *testing.T) {
s := FormatDiscoverySummary(nil)
if s != "no plugins found" {
t.Errorf("expected 'no plugins found', got %q", s)
}
}
// TestFormatDiscoverySummary on populated list.
func TestFormatDiscoverySummary_Plugins(t *testing.T) {
plugins := []Plugin{
{
Manifest: Manifest{ID: "test.one", Version: "1.0.0"},
},
{
Manifest: Manifest{ID: "test.two", Version: "2.0.0"},
},
}
s := FormatDiscoverySummary(plugins)
if s != "2 plugin(s): test.one@1.0.0, test.two@2.0.0" {
t.Errorf("unexpected summary: %q", s)
}
}