Files
HomeAgent/internal/plugins/skillmgr/skillmgr_test.go
JianFeeeee 3f834794c5 refactor: SDK module 路径统一改为 hologramagentsdk(59 文件 + go.mod)
## 为什么必须做

SDK 仓(独立仓库)已把 module 从 `github.com/JianFeeeee/homeagentsdk`
改名为 `hologramagentsdk`。主仓通过 `replace` 直接引用它的**物理目录**:

    replace github.com/JianFeeeee/homeagentsdk => ./third_party/homeagent-sdk

一旦 SDK 侧改成新 module 名,主仓立刻炸:

    go: ./third_party/homeagent-sdk@ used for two different module paths
        (github.com/JianFeeeee/hologramagentsdk and github.com/JianFeeeee/homeagentsdk)

Go **不允许**同一目录被两个 module 路径 replace。所以不能"新旧并存",
只能把主仓 59 个文件的 import 全部改到新名,并删掉旧 replace。

## 一个被证伪的前提

我最初按"third_party/homeagent-sdk 是独立仓库,不能在此仓修改其源码"
来处理,并因此判断两个仓的改动可以隔离。实际上主仓是靠 `replace` 指向
该目录的物理副本 —— **两个仓的改动不可能真正隔离**:SDK 侧改 module 名,
主仓必然跟着改 import。

这个前提应该在做任何改动之前用 `go list -m` 验证,而不是假设。

## 连带修掉两处

| 缺陷 | 性质 |
|---|---|
| `TestLuaSDKMockSingleSource` 失败 | SDK 的 `sdk/lua/sdk.lua` 变了,主仓内嵌副本未同步(跑 `scripts/sync-lua-sdk.sh` 修复) |
| `sdkModuleNames` 分散两处 | 已在 SDK 仓合并为单一数据源(见 SDK 仓 725732b) |

第一条是**副本一致性判据抓到的**,不是我自己发现的 —— 这类判据的价值在此:
两个仓必然有副本,靠人记住"改了 A 要同步 B"是做不到的。

## 验证

- `go build ./...` / `go vet ./...` 干净
- `go test ./... -count=1`:**58 包 0 失败**
- `internal/plugin`(Lua SDK 副本一致性)+ `internal/sdk` 全绿
- 主仓 `third_party/homeagent-sdk/` 与 SDK 仓 `main` 无差异
2026-10-23 15:51:02 +08:00

378 lines
11 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 skillmgr
import (
"archive/tar"
"compress/gzip"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/JianFeeeee/HologramAgent/internal/events"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// ---- 测试辅助 ----
type nilSettings struct{}
func (m *nilSettings) DataDir() string { return "/tmp/mock_data" }
func (m *nilSettings) Get(string) (interface{}, error) { return nil, nil }
func (m *nilSettings) Set(string, interface{}) error { return nil }
func (m *nilSettings) SetSecret(string, interface{}) error { return nil }
func (m *nilSettings) GetSecret(string) (string, error) { return "", nil }
// IsSecret:E-3 起 SettingsAPI 要求实现(判断某键是否按密文存储)。
func (m *nilSettings) IsSecret(string) bool { return false }
func (m *nilSettings) List(string) ([]string, error) { return nil, nil }
func (m *nilSettings) GetCore(string) (interface{}, error) { return nil, nil }
func (m *nilSettings) SetCore(string, interface{}) error { return nil }
func (m *nilSettings) ListCore(string) ([]string, error) { return nil, nil }
func (m *nilSettings) GetPlugin(string, string) (interface{}, error) { return nil, nil }
func (m *nilSettings) SetPlugin(string, string, interface{}) error { return nil }
func (m *nilSettings) ListPlugin(string, string) ([]string, error) { return nil, nil }
func (m *nilSettings) RegisterDef(pubsdk.ConfigDef) {}
func (m *nilSettings) Defs(string) []*pubsdk.ConfigDef { return nil }
func (m *nilSettings) Dump() map[string]interface{} { return nil }
func (m *nilSettings) Plugins() []string { return nil }
func (m *nilSettings) DefsCore(string) []*sdk.ConfigDef { return nil }
func (m *nilSettings) DefsPlugin(string, string) []*sdk.ConfigDef { return nil }
func (m *nilSettings) Remove(string) error { return nil }
func (m *nilSettings) RemoveCore(string) error { return nil }
func (m *nilSettings) RemovePlugin(string, string) error { return nil }
// toolSpy 记录注册的工具,并可按名调用 handler。
type toolSpy struct {
mu sync.Mutex
handlers map[string]func(args map[string]interface{}) (interface{}, error)
}
func newToolSpy() *toolSpy {
return &toolSpy{handlers: map[string]func(map[string]interface{}) (interface{}, error){}}
}
func (s *toolSpy) Register(name string, def interface{}, h func(args map[string]interface{}) (interface{}, error)) error {
s.mu.Lock()
defer s.mu.Unlock()
s.handlers[name] = h
return nil
}
func (s *toolSpy) call(t *testing.T, name string, args map[string]interface{}) (interface{}, error) {
t.Helper()
s.mu.Lock()
h, ok := s.handlers[name]
s.mu.Unlock()
if !ok {
t.Fatalf("tool %q not registered", name)
}
return h(args)
}
// newTestPlugin 构造带真实事件总线的 skillmgr 实例。
func newTestPlugin(t *testing.T) (*Plugin, *toolSpy, *events.Bus) {
t.Helper()
dir := t.TempDir()
bus := events.NewBus()
p := New("skillmgr", dir)
reg := func(name string, def interface{}, h func(args map[string]interface{}) (interface{}, error)) error {
_ = name
_ = def
_ = h
return nil
}
_ = reg // 占位避免误用
spy := newToolSpy()
cfgSDK := sdk.New("skillmgr", sdk.SDKConfig{
RegTool: func(name string, def sdk.ToolDef, h sdk.ToolHandler) error {
return spy.Register(name, def, func(args map[string]interface{}) (interface{}, error) {
return h(args)
})
},
Settings: &nilSettings{},
EventBus: bus,
})
if err := p.Start(cfgSDK); err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(func() { p.Stop() })
return p, spy, bus
}
func writeSkill(t *testing.T, dir, name, description string) string {
t.Helper()
sd := filepath.Join(dir, name)
os.MkdirAll(sd, 0755)
content := "---\nname: " + name + "\nversion: 1.0.0\n---\n\n# " + name + "\n\n" + description + "\n"
if err := os.WriteFile(filepath.Join(sd, SkillFileName), []byte(content), 0644); err != nil {
t.Fatal(err)
}
return sd
}
const sampleSkillContent = `---
name: demo-skill
version: 0.2.0
author: tester
---
# demo-skill
用于测试的演示技能。
## 使用时机
测试时使用。
## 操作步骤
### step-1
第一步说明。
- target: 目标参数
执行示例:
` + "```bash\necho demo\n```" + `
`
// ---- 用例 ----
func TestScanExistingLoadsPureSkillsOnly(t *testing.T) {
dir := t.TempDir()
writeSkill(t, dir, "alpha", "alpha desc")
writeSkill(t, dir, "beta", "beta desc")
// sidecar 目录(main.js)应被跳过
scDir := filepath.Join(dir, "sidecar-thing")
os.MkdirAll(scDir, 0755)
os.WriteFile(filepath.Join(scDir, "main.js"), []byte("//"), 0644)
// OC plugin 目录应被跳过
ocDir := filepath.Join(dir, "oc-thing")
os.MkdirAll(ocDir, 0755)
os.WriteFile(filepath.Join(ocDir, "openclaw.plugin.json"), []byte("{}"), 0644)
// 隐藏目录跳过
hd := filepath.Join(dir, ".hidden")
os.MkdirAll(hd, 0755)
os.WriteFile(filepath.Join(hd, SkillFileName), []byte("x"), 0644)
p := New("skillmgr", dir)
bus := events.NewBus()
sdkInst := sdk.New("skillmgr", sdk.SDKConfig{
Settings: &nilSettings{},
EventBus: bus,
})
if err := p.Start(sdkInst); err != nil {
t.Fatal(err)
}
defer p.Stop()
names := map[string]bool{}
for _, e := range p.snapshot() {
names[e.sk.Name()] = true
}
if !names["alpha"] || !names["beta"] {
t.Fatalf("expected alpha+beta loaded, got %v", names)
}
if names["sidecar-thing"] || names["oc-thing"] || names[".hidden"] {
t.Fatalf("sidecar/OC/hidden should not load, got %v", names)
}
}
func TestSkillDetectedEventHandoff(t *testing.T) {
p, _, bus := newTestPlugin(t)
// 模拟 clawhubadapter 移交事件
dir := p.skillsDir
sd := filepath.Join(dir, "handed")
os.MkdirAll(sd, 0755)
os.WriteFile(filepath.Join(sd, SkillFileName), []byte(sampleSkillContent), 0644)
bus.Publish(&events.Event{
Type: detectEvent,
Source: "clawhubadapter",
Payload: map[string]interface{}{"path": sd},
Timestamp: time.Now().Unix(),
})
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, ok := p.get("handed"); ok {
break
}
time.Sleep(10 * time.Millisecond)
}
if _, ok := p.get("handed"); !ok {
t.Fatal("handed-off skill not registered after event")
}
}
func TestCreateValidateLoadFlow(t *testing.T) {
p, spy, _ := newTestPlugin(t)
// 两步式:先骨架
out, err := spy.call(t, "skill_create", map[string]interface{}{
"name": "weather-notify",
"description": "天气通知",
})
if err != nil {
t.Fatalf("create skeleton: %v", err)
}
_ = out
if !fileExists(filepath.Join(p.skillsDir, "weather-notify", SkillFileName)) {
t.Fatal("skeleton SKILL.md missing")
}
// 第二步:完整内容覆盖 → 自动加载
if _, err := spy.call(t, "skill_create", map[string]interface{}{
"name": "weather-notify",
"content": sampleSkillContent,
}); err != nil {
t.Fatalf("create full: %v", err)
}
if _, ok := p.get("weather-notify"); !ok {
t.Fatal("full-content skill not loaded under its own name")
}
// 空白 content 走模板分支:生成骨架而非报错
if _, err := spy.call(t, "skill_create", map[string]interface{}{
"name": "bad-skill",
"description": "",
}); err != nil {
t.Fatalf("skeleton-only create should succeed: %v", err)
}
if !fileExists(filepath.Join(p.skillsDir, "bad-skill", SkillFileName)) {
t.Fatal("bad-skill skeleton missing")
}
// 有内容但缺描述的 content 被校验拒绝
if _, err := spy.call(t, "skill_create", map[string]interface{}{
"name": "no-desc",
"content": "---\nname: no-desc\n---\n\n## 步骤\n",
}); err == nil {
t.Fatal("content without description should be rejected by ValidateSKILLContent")
}
// 非法名称被拒
if _, err := spy.call(t, "skill_create", map[string]interface{}{"name": "../evil"}); err == nil {
t.Fatal("path traversal name should be rejected")
}
}
func TestExportInstallRoundTrip(t *testing.T) {
p, spy, _ := newTestPlugin(t)
// 先创建并加载一个 skill
if _, err := spy.call(t, "skill_create", map[string]interface{}{
"name": "roundtrip",
"content": strings.ReplaceAll(sampleSkillContent, "demo-skill", "roundtrip"),
}); err != nil {
t.Fatal(err)
}
outDir := filepath.Join(p.skillsDir, "..", "exports")
packPath := filepath.Join(outDir, "roundtrip.skm")
if _, err := spy.call(t, "skill_export", map[string]interface{}{"name": "roundtrip"}); err != nil {
t.Fatalf("export: %v", err)
}
if !fileExists(packPath) {
t.Fatalf("pack missing at %s", packPath)
}
// 卸载 + 删文件后从包重装
if _, err := spy.call(t, "skill_unload", map[string]interface{}{"name": "roundtrip", "delete_files": true}); err != nil {
t.Fatal(err)
}
if _, ok := p.get("roundtrip"); ok {
t.Fatal("skill should be unloaded")
}
if fileExists(filepath.Join(p.skillsDir, "roundtrip")) {
t.Fatal("files should be deleted")
}
if _, err := spy.call(t, "skill_install", map[string]interface{}{"source": packPath}); err != nil {
t.Fatalf("install: %v", err)
}
e, ok := p.get("roundtrip")
if !ok {
t.Fatal("reinstalled skill not loaded")
}
if e.sk.Version() != "0.2.0" {
t.Fatalf("unexpected version %q", e.sk.Version())
}
}
func TestEnableDisable(t *testing.T) {
p, spy, _ := newTestPlugin(t)
writeSkill(t, p.skillsDir, "toggle", "toggle desc")
p.scanExisting()
if _, err := spy.call(t, "skill_disable", map[string]interface{}{"name": "toggle"}); err != nil {
t.Fatal(err)
}
if e, _ := p.get("toggle"); e.enabled {
t.Fatal("should be disabled")
}
if _, err := spy.call(t, "skill_enable", map[string]interface{}{"name": "toggle"}); err != nil {
t.Fatal(err)
}
if e, _ := p.get("toggle"); !e.enabled {
t.Fatal("should be enabled")
}
}
func TestPackTarSlipRejected(t *testing.T) {
tmp := t.TempDir()
// 构造带 .. 逃逸条目的恶意包
packPath := filepath.Join(tmp, "evil.skm")
f, _ := os.Create(packPath)
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
hdr := &tar.Header{Name: "../../../evil.txt", Mode: 0644, Size: 3}
tw.WriteHeader(hdr)
tw.Write([]byte("bad"))
tw.Close()
gz.Close()
f.Close()
dst := filepath.Join(tmp, "out")
if _, err := unpackSkill(packPath, dst); err == nil {
t.Fatal("tar slip entry must be rejected")
}
}
func TestSkillIndex(t *testing.T) {
p, spy, _ := newTestPlugin(t)
if idx := p.SkillIndex(); idx != "" {
t.Fatalf("empty registry should give empty index, got %q", idx)
}
if _, err := spy.call(t, "skill_create", map[string]interface{}{
"name": "idx-test",
"content": strings.ReplaceAll(sampleSkillContent, "demo-skill", "idx-test"),
}); err != nil {
t.Fatal(err)
}
idx := p.SkillIndex()
if !strings.Contains(idx, "idx-test v0.2.0") || !strings.Contains(idx, "用于测试的演示技能。") {
t.Fatalf("index missing name/desc: %q", idx)
}
// 禁用后不出现在索引里
spy.call(t, "skill_disable", map[string]interface{}{"name": "idx-test"})
if idx := p.SkillIndex(); strings.Contains(idx, "idx-test") {
t.Fatalf("disabled skill should be excluded from index: %q", idx)
}
}