Files
HomeAgent/internal/plugins/pluginmgr/upgrade_test.go
JianFeeeee ae42e486de feat(pluginmgr): 插件更新接口(upgrade/downgrade 保留配置)+ skill_install overwrite
内核 Registry 拆出 StopAndUnload:
- 停止并从注册表移除插件但保留 config_<name> 表
- 不触发 onRemove 回调(那是删除专用语义)
- RemovePlugin 改为追加清理配置表示清除,更新场景调 StopAndUnload

pluginmgr:
- installFromData/installFromURL/installFromPath 加 overwrite 参数
- 已存在+overwrite=true:StopAndUnload→备份旧目录→解压新包→失败回滚→
  返回 action=upgraded/downgraded/reinstalled+previous_version+config_kept
- 已存在+overwrite=false:返回 error+hint(指向 overwrite 用法)
- cmpVersion 点分版本号数字比较(非字典序)
- 测试覆盖:首次安装→重装拒绝→升级保留配置→降级→失败回滚

skill_install 加 overwrite 参数:
- 同名技能存在时先卸载旧实例+删除目录再安装新包

SDK PluginMgr 接口同步加 StopAndUnload(name string) error

工具链 plugindev 已重建到 /usr/local/bin(7/29→8/25 版本)
QQ 插件诊断日志版(webhook recv 到达+isAtBot 失败日志)已打包并
通过 upgrade 接口热更新部署,配置保留验证通过。
2026-08-25 22:02:17 +08:00

219 lines
7.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package pluginmgr
import (
"archive/zip"
"bytes"
"encoding/json"
"os"
"path/filepath"
"sync"
"testing"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func TestCmpVersion(t *testing.T) {
cases := []struct{ a, b string; want int }{
{"1.0.0", "1.0.0", 0},
{"1.0.1", "1.0.0", 1},
{"1.0.0", "1.0.1", -1},
{"1.0", "1.0.0", 0},
{"v2.0.0", "1.9.9", 1},
{"2.0.0", "10.0.0", -1}, // 数字比较而非字典序
{"1.0.0-alpha", "1.0.0", 0}, // 非数字段按 0
}
for _, c := range cases {
if got := cmpVersion(c.a, c.b); got != c.want {
t.Errorf("cmpVersion(%q,%q)=%d want %d", c.a, c.b, got, c.want)
}
}
}
// ---- 最小 mock SDK ----
type pmSettings struct{}
func (m *pmSettings) Get(string) (interface{}, error) { return nil, nil }
func (m *pmSettings) Set(string, interface{}) error { return nil }
func (m *pmSettings) List(string) ([]string, error) { return nil, nil }
func (m *pmSettings) GetCore(string) (interface{}, error) { return nil, nil }
func (m *pmSettings) SetCore(string, interface{}) error { return nil }
func (m *pmSettings) ListCore(string) ([]string, error) { return nil, nil }
func (m *pmSettings) GetPlugin(string, string) (interface{}, error) { return nil, nil }
func (m *pmSettings) SetPlugin(string, string, interface{}) error { return nil }
func (m *pmSettings) ListPlugin(string, string) ([]string, error) { return nil, nil }
func (m *pmSettings) RegisterDef(sdk.ConfigDef) {}
func (m *pmSettings) Defs(string) []*sdk.ConfigDef { return nil }
func (m *pmSettings) Dump() map[string]interface{} { return nil }
func (m *pmSettings) Plugins() []string { return nil }
func (m *pmSettings) DefsCore(string) []*sdk.ConfigDef { return nil }
func (m *pmSettings) DefsPlugin(string, string) []*sdk.ConfigDef { return nil }
func (m *pmSettings) Remove(string) error { return nil }
func (m *pmSettings) RemoveCore(string) error { return nil }
func (m *pmSettings) RemovePlugin(string, string) error { return nil }
// fakePluginMgr 记录调用StopAndUnload 只记标志,不真正操作。
type fakePluginMgr struct {
mu sync.Mutex
stopAndUnloads []string
}
func (f *fakePluginMgr) ListLoadedPlugins() []string { return nil }
func (f *fakePluginMgr) ListDisabledPlugins() []sdk.DisabledPluginInfo { return nil }
func (f *fakePluginMgr) IsPluginDisabled(string) bool { return false }
func (f *fakePluginMgr) IsBuiltinPlugin(string) bool { return false }
func (f *fakePluginMgr) DisablePlugin(string, string) error { return nil }
func (f *fakePluginMgr) EnablePlugin(string) error { return nil }
func (f *fakePluginMgr) RemovePlugin(string) error { return nil }
func (f *fakePluginMgr) StopAndUnload(name string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.stopAndUnloads = append(f.stopAndUnloads, name)
return nil
}
func (f *fakePluginMgr) ReloadPlugins() (string, error) { return "", nil }
func (f *fakePluginMgr) ReloadOne(string) error { return nil }
func (f *fakePluginMgr) PluginMetas() map[string]sdk.PluginMeta {
return map[string]sdk.PluginMeta{}
}
func (f *fakePluginMgr) PluginDir() string { return "" }
// buildHmap 构造一个最小 .hmap 包。
func buildHmap(t *testing.T, name, version string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
manifest := map[string]interface{}{
"name": name, "name_zh": name, "name_en": name,
"version": version, "entry": "plugin.so",
}
mData, _ := json.Marshal(manifest)
f, _ := zw.Create("plugin.json")
f.Write(mData)
bin, _ := zw.Create("plugin.so")
bin.Write([]byte("binary-" + name + "-" + version))
zw.Close()
return buf.Bytes()
}
func TestInstallThenUpgradeKeepsConfig(t *testing.T) {
dir := t.TempDir()
fm := &fakePluginMgr{}
bus := events.NewBus()
sdkInst := sdk.New("pluginmgr", sdk.SDKConfig{
Settings: &pmSettings{},
EventBus: bus,
PluginMgr: fm,
})
p := &Plugin{pluginDir: dir, sdk: sdkInst}
// 1. 首次安装 v1.0.0
r1, _ := p.installFromData(buildHmap(t, "demo", "1.0.0"), false)
m1 := r1.(map[string]interface{})
if m1["status"] != "installed" {
t.Fatalf("install failed: %v", m1)
}
if _, err := os.Stat(filepath.Join(dir, "demo", "plugin.json")); err != nil {
t.Fatalf("installed dir missing: %v", err)
}
// 2. 不带 overwrite 重装 → 报 already exists + remove_first hint
r2, _ := p.installFromData(buildHmap(t, "demo", "1.0.0"), false)
m2 := r2.(map[string]interface{})
if m2["error"] != "plugin already exists" || m2["hint"] == "" {
t.Fatalf("expected already-exists with hint, got %v", m2)
}
if m2["current"] != "1.0.0" {
t.Fatalf("current version not reported: %v", m2)
}
// 3. overwrite 升级 v1.0.0 → v2.0.0
r3, _ := p.installFromData(buildHmap(t, "demo", "2.0.0"), true)
m3 := r3.(map[string]interface{})
if m3["status"] != "installed" || m3["action"] != "upgraded" {
t.Fatalf("upgrade failed: %v", m3)
}
if m3["previous_version"] != "1.0.0" {
t.Fatalf("previous_version = %v", m3["previous_version"])
}
if m3["config_kept"] != true {
t.Fatalf("config_kept should be true: %v", m3)
}
// StopAndUnload 应被调用且不触发 RemovePlugin不删配置
fm.mu.Lock()
calls := append([]string{}, fm.stopAndUnloads...)
fm.mu.Unlock()
if len(calls) != 1 || calls[0] != "demo" {
t.Fatalf("StopAndUnload not called once with demo: %v", calls)
}
// 新二进制写入
soData, err := os.ReadFile(filepath.Join(dir, "demo", "plugin.so"))
if err != nil {
t.Fatalf("read new so: %v", err)
}
if string(soData) != "binary-demo-2.0.0" {
t.Fatalf("so not overwritten: %q", string(soData))
}
// 4. 降级 v2.0.0 → v1.5.0
r4, _ := p.installFromData(buildHmap(t, "demo", "1.5.0"), true)
m4 := r4.(map[string]interface{})
if m4["action"] != "downgraded" {
t.Fatalf("downgrade action = %v", m4)
}
}
func TestExtractFailureRollsBack(t *testing.T) {
dir := t.TempDir()
fm := &fakePluginMgr{}
bus := events.NewBus()
sdkInst := sdk.New("pluginmgr", sdk.SDKConfig{
Settings: &pmSettings{},
EventBus: bus,
PluginMgr: fm,
})
p := &Plugin{pluginDir: dir, sdk: sdkInst}
// 先装 v1.0.0
if r, _ := p.installFromData(buildHmap(t, "rollback", "1.0.0"), false); r.(map[string]interface{})["status"] != "installed" {
t.Fatal("install failed")
}
// 构造损坏包zip 但缺 plugin.jsonextractPackage 会失败)
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
f, _ := zw.Create("plugin.so")
f.Write([]byte("corrupt"))
zw.Close()
// 畸形包在 validatePackage 层就拒绝,未达 extract——模拟 extract 失败:
// 直接注入非法平台文件触发 extractPackage 错误
bad := buildHmap(t, "rollback", "9.9.9")
// 篡改使 extract 失败:附加一个越界路径
var rb bytes.Buffer
zw2 := zip.NewWriter(&rb)
f2, _ := zw2.Create("../../evil")
f2.Write([]byte("x"))
mf, _ := zw2.Create("plugin.json")
mData, _ := json.Marshal(map[string]interface{}{"name": "rollback", "version": "9.9.9", "entry": "plugin.so"})
mf.Write(mData)
zw2.Close()
bad = rb.Bytes()
r, _ := p.installFromData(bad, true)
m := r.(map[string]interface{})
if m["error"] == nil {
t.Fatalf("expected error for corrupt package, got %v", m)
}
if m["rollback"] != nil {
t.Fatalf("rollback itself failed: %v", m)
}
// 旧版应被恢复
mfest, err := plugin.ReadManifest(filepath.Join(dir, "rollback"))
if err != nil || mfest.Version != "1.0.0" {
t.Fatalf("old version not restored: %v / %v", mfest, err)
}
}