Files
2026-08-14 23:41:57 +08:00

138 lines
4.5 KiB
Go

// Tests for plugin discovery (US-016, #132): executable detection, deterministic
// order, fault tolerance (a bad plugin is skipped, not fatal), and empty/missing
// directory handling.
package plugin
import (
"bytes"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// TestDiscoverMissingDir checks a missing directory yields an empty manager.
func TestDiscoverMissingDir(t *testing.T) {
m, err := Discover(filepath.Join(t.TempDir(), "nope"), nil, nil)
if err != nil {
t.Fatalf("Discover missing dir: %v", err)
}
if len(m.Plugins()) != 0 {
t.Errorf("want no plugins, got %d", len(m.Plugins()))
}
}
// TestDiscoverSkipsNonExecutable checks non-executable files are ignored.
func TestDiscoverSkipsNonExecutable(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("unix executable-bit semantics not applicable on windows")
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(dir, "subdir"), 0o755); err != nil {
t.Fatal(err)
}
m, err := Discover(dir, nil, nil)
if err != nil {
t.Fatalf("Discover: %v", err)
}
if len(m.Plugins()) != 0 {
t.Errorf("non-executable/dir entries should be skipped, got %d plugins", len(m.Plugins()))
}
}
// TestDiscoverLoadsAndIsolatesBad checks that a good plugin loads and a bad one
// (executable that isn't a valid plugin) is logged and skipped rather than
// aborting discovery.
func TestDiscoverLoadsAndIsolatesBad(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell-script plugins are unix-only in this test")
}
dir := t.TempDir()
// Good plugin: compiled from echoPluginSrc, placed inside the discovery dir.
good := buildTestPlugin(t, "aaa-echo", echoPluginSrc)
if err := os.Rename(good, filepath.Join(dir, "aaa-echo")); err != nil {
t.Fatal(err)
}
// Bad plugin: an executable that immediately exits without speaking the
// protocol, so the initialize handshake fails.
bad := filepath.Join(dir, "zzz-bad")
if err := os.WriteFile(bad, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatal(err)
}
var warn bytes.Buffer
m, err := Discover(dir, &warn, os.Stderr)
if err != nil {
t.Fatalf("Discover: %v", err)
}
defer m.Close()
if len(m.Plugins()) != 1 {
t.Fatalf("want 1 good plugin loaded, got %d", len(m.Plugins()))
}
if m.Plugins()[0].Manifest.Name != "echo" {
t.Errorf("loaded wrong plugin: %q", m.Plugins()[0].Manifest.Name)
}
if !strings.Contains(warn.String(), "zzz-bad") {
t.Errorf("bad plugin should be logged, warn=%q", warn.String())
}
if tools := m.Tools(); len(tools) != 1 || tools[0].Name() != "shout" {
t.Errorf("aggregated tools = %+v, want one 'shout'", tools)
}
}
// TestManagerCommandsAggregatesInLoadOrder checks that Commands() returns every
// loaded plugin's commands in load order (plugins ordered by discovery, and
// within a plugin by manifest order), each carrying its owning plugin.
func TestManagerCommandsAggregatesInLoadOrder(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell-script/compiled plugins are unix-only in this test")
}
dir := t.TempDir()
// Two command plugins. Load order is by filename, so "a-cmd" loads before
// "b-cmd"; within each plugin the manifest declares greet then bye.
a := buildTestPlugin(t, "a-cmd", cmdPluginSrc)
if err := os.Rename(a, filepath.Join(dir, "a-cmd")); err != nil {
t.Fatal(err)
}
b := buildTestPlugin(t, "b-cmd", cmdPluginSrc)
if err := os.Rename(b, filepath.Join(dir, "b-cmd")); err != nil {
t.Fatal(err)
}
m, err := Discover(dir, nil, os.Stderr)
if err != nil {
t.Fatalf("Discover: %v", err)
}
defer m.Close()
cmds := m.Commands()
// Both plugins report the same name "cmd" (from cmdPluginSrc's manifest), so
// the two loaded *Plugin pointers are what distinguish load order.
if len(m.Plugins()) != 2 {
t.Fatalf("want 2 plugins loaded, got %d", len(m.Plugins()))
}
p0, p1 := m.Plugins()[0], m.Plugins()[1]
want := []PluginCommand{
{Plugin: p0, Spec: CommandSpec{Name: "greet", Description: "greets"}},
{Plugin: p0, Spec: CommandSpec{Name: "bye", Description: "farewell"}},
{Plugin: p1, Spec: CommandSpec{Name: "greet", Description: "greets"}},
{Plugin: p1, Spec: CommandSpec{Name: "bye", Description: "farewell"}},
}
if len(cmds) != len(want) {
t.Fatalf("Commands() len = %d, want %d (%+v)", len(cmds), len(want), cmds)
}
for i, w := range want {
if cmds[i].Plugin != w.Plugin || cmds[i].Spec != w.Spec {
t.Errorf("Commands()[%d] = {%p, %+v}, want {%p, %+v}", i, cmds[i].Plugin, cmds[i].Spec, w.Plugin, w.Spec)
}
}
}