Files
HomeAgent/internal/plugins/pluginmgr/kernel_update_test.go
JianFeeeee 475aac867b refactor(update): 内核与插件更新合并为 update 子系统(三端共享一个底层)
## 为什么合并(用户口径:更新部分应独立为子系统)

之前 kernelupgrade / pluginupdate 两个包,但它们共享三样东西,
分开写就要各复制一份,然后慢慢不一致:

① **版本比较** —— 合并前共 6 个函数做同一件事:
   kernel 侧 ParseSemver / CmpSemver / versionNewer / trimTagV
   plugin 侧 trimV / isNewer / semverNums(还自带一份正则)
   且**行为不一致**:kernel 侧只挡 len<2,"1.4" 漏过去被填成 1.4.0;
   plugin 侧的正则要求两段所以也漏。两份都把 "1.4" 当合法版本。

② **两阶段交互**(检查 → 确认 → 执行)—— 同一套用户口径。

③ **失败要响亮** —— 都不静默降级。

## 收敛结果

internal/update/
  doc.go     子系统头:职责边界、三端共享契约、为何不分家
  semver.go  ParseSemver / CmpSemver / Newer / TrimTag(**唯一一份**)
  kernel.go  内核升级闭环(解包→校验→备份→替换→重启→复核→回滚)
  release.go GitHub Release 查询
  plugin.go  插件升级(查仓库→拉源码→hmapdev 编译→产物)

三端共享:
  agent 工具(plugin_update / kernel_update)  ┐
  WebAPI(/plugins/update、/kernel/update)    ├─ internal/update
  CLI(homed-upgrade)                         ┘

入口层只做参数解析与结果呈现,不含业务逻辑 —— 否则会出现
"agent 说没有新版、WebUI 却升了级"的分裂状态。

## 合并时抓到的两个真实 bug

① **ParseSemver 只挡 len<2** ⇒ "1.4" 被零值填成 1.4.0,
   「1.4 → 1.4.0」被判完全相同(说"无需升级"),而两者内容未必相同。
   现在要求恰好三段。

② **不认 v 前缀** ⇒ GitHub release tag 全是 v1.4.0 形态,
   CLI 对"当前 v1.4.0 vs 包内 v1.4.0"判不可比、只能退回字符串比较。
   现在 TrimTag 去 v 前缀后再比。

## 新增入口

· plugin_update(agent 工具)+ GET/POST /plugins/update
  —— 两阶段:apply=false 只检查(列出 updatable / no_repo 两类,
     后者是"未声明仓库、无法自动更新"的内置/私有插件);
     apply=true 需显式 plugins=all 或名单(不默认全升)
· kernel_update(agent 工具)+ GET/POST /kernel/update
  —— 两阶段同构;apply 只收本机包路径(URL 下载是 CLI 职责,
     agent 不该在推理中发起长下载)

判据:
· internal/update: Newer / TrimTag / ParseSemver 的边界(含上述两个 bug 的回归)
· pluginmgr 入口层 5 条:检查形状、apply 必须显式给范围、
  kernel apply 必须给 pkg 且指向 homed-upgrade、拒绝目录当包
2026-10-26 01:05:09 +08:00

106 lines
3.5 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 (
"strings"
"testing"
)
// ── plugin_update / kernel_update 的入口层判据 ────────────────
//
// 这里只测**入口层**(参数解析、范围选择、结果形状);
// 底层逻辑(Check / Upgrade / DoUpgrade)的判据在各自包里:
// internal/update — 拉源码、编译、版本比较
// internal/update — 版本比较、解包、替换、回滚
// 入口层若长出业务逻辑,就违背了"三端共享一个底层"的架构约束。
// 判据 1:plugin_update 检查阶段(apply=false)不要求 plugins 参数,
// 且结果含 updatable / no_repo 两类 —— 两者必须分开。
//
// 混在一起的后果:用户无法区分"没更新"和"根本不能自动更新"(内置插件)。
func TestPluginUpdate_CheckShape(t *testing.T) {
p := &Plugin{pluginDir: t.TempDir()} // 空目录:没有已装插件
out, err := p.pluginUpdate(false, "")
if err != nil {
t.Fatalf("检查不应失败: %v", err)
}
m, ok := out.(map[string]interface{})
if !ok {
t.Fatalf("返回应为 map,实际 %T", out)
}
if m["apply"] != false {
t.Errorf("apply 应为 false,实际 %v", m["apply"])
}
if _, ok := m["updatable"]; !ok {
t.Error("结果应有 updatable 字段")
}
if _, ok := m["no_repo"]; !ok {
t.Error("结果应有 no_repo 字段(未声明仓库的插件单独列出)")
}
}
// 判据 2:apply=true 但没给 plugins ⇒ 明确报错,不默认全升。
//
// 默认全升的危险:一次误触就把所有插件换了。
func TestPluginUpdate_ApplyRequiresScope(t *testing.T) {
p := &Plugin{pluginDir: t.TempDir()}
out, err := p.pluginUpdate(true, "")
if err != nil {
t.Fatalf("应返回可呈现的错误而不是 error: %v", err)
}
m := out.(map[string]interface{})
if _, hasErr := m["error"]; !hasErr {
t.Errorf("apply=true 缺 plugins 时必须报错,实际: %v", m)
}
}
// 判据 3:kernel_update 检查阶段不依赖包存在,返回版本比较结果。
func TestKernelUpdate_CheckShape(t *testing.T) {
p := &Plugin{pluginDir: t.TempDir()}
out, err := p.kernelUpdateCheck()
if err != nil {
t.Fatalf("检查不应失败: %v", err)
}
m := out.(map[string]interface{})
if m["apply"] != false {
t.Errorf("apply 应为 false,实际 %v", m["apply"])
}
// current/latest/reason 三者在"读不到本机版本"时也要给
if m["reason"] == nil {
t.Error("结果应有 reason 字段(说明能否比较/为何不能)")
}
}
// 判据 4:kernel_update apply 缺 pkg ⇒ 明确报错并指向 CLI。
func TestKernelUpdate_ApplyRequiresPkg(t *testing.T) {
p := &Plugin{pluginDir: t.TempDir()}
out, err := p.kernelUpdateApply("")
if err != nil {
t.Fatalf("应返回可呈现的错误: %v", err)
}
m := out.(map[string]interface{})
if !strings.Contains(fmtSprint(m["error"]), "pkg") {
t.Errorf("错误应说明需要 pkg,实际: %v", m["error"])
}
// 并应提示下载走 CLI(agent 不该在推理中发起长下载)
if !strings.Contains(fmtSprint(m["error"]), "homed-upgrade") {
t.Errorf("错误应指向 homed-upgrade,实际: %v", m["error"])
}
}
// 判据 5:kernel_update apply 的 pkg 是目录/不存在 ⇒ 拒绝。
func TestKernelUpdate_ApplyRejectsBadPkg(t *testing.T) {
p := &Plugin{pluginDir: t.TempDir()}
out, _ := p.kernelUpdateApply(t.TempDir()) // 目录不是包
m := out.(map[string]interface{})
if _, hasErr := m["error"]; !hasErr {
t.Errorf("目录不应被当作安装包,实际: %v", m)
}
}
func fmtSprint(v interface{}) string {
if s, ok := v.(string); ok {
return s
}
return ""
}