Files
2026-07-13 13:00:08 +08:00

495 lines
15 KiB
Go

package skill
import (
"os"
"path/filepath"
"strings"
"testing"
)
// --- IsValidName ---
func TestIsValidName(t *testing.T) {
cases := []struct {
name string
want bool
}{
{"valid-name", true},
{"CamelCase", true},
{"with.dot", true},
{"with_underscore", true},
{"a", true},
{"A123", true},
{"", false},
{"-starts-dash", false},
{"has space", false},
{"has/slash", false},
{strings.Repeat("a", 65), false}, // too long
{strings.Repeat("a", 64), true}, // max length
}
for _, c := range cases {
if got := IsValidName(c.name); got != c.want {
t.Errorf("IsValidName(%q) = %v, want %v", c.name, got, c.want)
}
}
}
// --- splitFrontmatter ---
func TestSplitFrontmatterNoFence(t *testing.T) {
fm, body := splitFrontmatter("just body")
if len(fm) != 0 {
t.Errorf("expected empty fm, got %v", fm)
}
if body != "just body" {
t.Errorf("body = %q", body)
}
}
func TestSplitFrontmatterUnclosed(t *testing.T) {
fm, body := splitFrontmatter("---\nkey: val\n\nno closing")
if len(fm) != 0 {
t.Errorf("unclosed fence should return empty fm, got %v", fm)
}
if !strings.Contains(body, "---") {
t.Errorf("body should contain original: %q", body)
}
}
func TestSplitFrontmatterEmpty(t *testing.T) {
fm, body := splitFrontmatter("")
if len(fm) != 0 {
t.Errorf("empty input should return empty fm, got %v", fm)
}
if body != "" {
t.Errorf("body = %q", body)
}
}
func TestSplitFrontmatterQuotedValues(t *testing.T) {
fm, _ := splitFrontmatter("---\ndescription: \"quoted\"\n---\n")
if fm["description"] != "quoted" {
t.Errorf("description = %q", fm["description"])
}
}
// --- parseAllowedTools ---
func TestParseAllowedToolsEmpty(t *testing.T) {
if got := parseAllowedTools(""); got != nil {
t.Errorf("empty = %v, want nil", got)
}
if got := parseAllowedTools(" "); got != nil {
t.Errorf("whitespace = %v, want nil", got)
}
}
func TestParseAllowedToolsSingle(t *testing.T) {
got := parseAllowedTools("bash")
if len(got) != 1 || got[0] != "bash" {
t.Errorf("single = %v", got)
}
}
func TestParseAllowedToolsMultiple(t *testing.T) {
got := parseAllowedTools("read_file, grep, bash")
if len(got) != 3 {
t.Errorf("count = %d, want 3", len(got))
}
if got[0] != "read_file" || got[1] != "grep" || got[2] != "bash" {
t.Errorf("tools = %v", got)
}
}
func TestParseAllowedToolsTrailingComma(t *testing.T) {
got := parseAllowedTools("bash,")
if len(got) != 1 || got[0] != "bash" {
t.Errorf("trailing comma = %v", got)
}
}
func TestParseAllowedToolsExtraSpaces(t *testing.T) {
got := parseAllowedTools(" bash , grep ")
if len(got) != 2 || got[0] != "bash" || got[1] != "grep" {
t.Errorf("extra spaces = %v", got)
}
}
// --- parseRunAs ---
func TestParseRunAsExplicit(t *testing.T) {
if parseRunAs("subagent", "", "") != RunSubagent {
t.Error("explicit subagent should return RunSubagent")
}
if parseRunAs("inline", "", "") != RunInline {
t.Error("explicit inline should return RunInline")
}
}
func TestParseRunAsContextFork(t *testing.T) {
if parseRunAs("", "fork", "") != RunSubagent {
t.Error("context: fork should return RunSubagent")
}
if parseRunAs("", "FORK", "") != RunSubagent {
t.Error("context: FORK should return RunSubagent")
}
}
func TestParseRunAsAgent(t *testing.T) {
if parseRunAs("", "", "some-agent") != RunSubagent {
t.Error("non-empty agent should return RunSubagent")
}
}
func TestParseRunAsDefault(t *testing.T) {
if parseRunAs("", "", "") != RunInline {
t.Error("all empty should default to RunInline")
}
if parseRunAs("unknown", "", "") != RunInline {
t.Error("unknown runAs should default to RunInline")
}
}
// --- resolveCustomPaths ---
func TestResolveCustomPathsTilde(t *testing.T) {
home := t.TempDir()
got := resolveCustomPaths([]string{"~/skills"}, "/base", home)
if len(got) != 1 || got[0] != filepath.Join(home, "skills") {
t.Errorf("tilde expansion = %v", got)
}
}
func TestResolveCustomPathsRelative(t *testing.T) {
base := t.TempDir()
got := resolveCustomPaths([]string{"./my-skills"}, base, "/home")
if len(got) != 1 || got[0] != filepath.Join(base, "my-skills") {
t.Errorf("relative = %v", got)
}
}
func TestResolveCustomPathsAbsolute(t *testing.T) {
abs := filepath.Join(t.TempDir(), "absolute", "path")
got := resolveCustomPaths([]string{abs}, "/base", "/home")
if len(got) != 1 || got[0] != abs {
t.Errorf("absolute = %v", got)
}
}
func TestResolveCustomPathsEmpty(t *testing.T) {
got := resolveCustomPaths([]string{"", " "}, "/base", "/home")
if len(got) != 0 {
t.Errorf("empty paths should be filtered, got %v", got)
}
}
// --- dedupePaths ---
func TestDedupePaths(t *testing.T) {
got := dedupePaths([]string{"/a", "/b", "/a", "/c", "/b"})
if len(got) != 3 || got[0] != "/a" || got[1] != "/b" || got[2] != "/c" {
t.Errorf("deduped = %v", got)
}
}
func TestDedupePathsEmpty(t *testing.T) {
got := dedupePaths(nil)
if len(got) != 0 {
t.Errorf("nil = %v", got)
}
}
// --- stubBody ---
func TestStubBody(t *testing.T) {
body := stubBody("my-skill")
if !strings.Contains(body, "name: my-skill") {
t.Error("stub should contain the skill name")
}
if !strings.Contains(body, "description:") {
t.Error("stub should contain description field")
}
if !strings.Contains(body, "# my-skill") {
t.Error("stub should contain the skill name as heading")
}
}
// --- Read edge cases ---
func TestReadInvalidName(t *testing.T) {
home := t.TempDir()
st := New(Options{HomeDir: home, DisableBuiltins: true})
_, ok := st.Read("invalid name!")
if ok {
t.Error("invalid name should return ok=false")
}
}
func TestReadNotFound(t *testing.T) {
home := t.TempDir()
st := New(Options{HomeDir: home, DisableBuiltins: true})
_, ok := st.Read("nonexistent")
if ok {
t.Error("nonexistent skill should return ok=false")
}
}
// --- Create edge cases ---
func TestCreateInvalidName(t *testing.T) {
home := t.TempDir()
st := New(Options{HomeDir: home, DisableBuiltins: true})
_, err := st.Create("invalid name!", ScopeGlobal)
if err == nil {
t.Error("invalid name should error")
}
}
func TestCreateProjectScopeRequiresRoot(t *testing.T) {
home := t.TempDir()
st := New(Options{HomeDir: home, DisableBuiltins: true})
_, err := st.Create("test", ScopeProject)
if err == nil {
t.Error("project scope without root should error")
}
}
func TestCreateDirectoryLayoutSkill(t *testing.T) {
home := t.TempDir()
skillsRoot := filepath.Join(home, ".reasonix", "skills", "existing", "SKILL.md")
os.MkdirAll(filepath.Dir(skillsRoot), 0o755)
os.WriteFile(skillsRoot, []byte("---\ndescription: exists\n---\nbody"), 0o644)
st := New(Options{HomeDir: home, DisableBuiltins: true})
_, err := st.Create("existing", ScopeGlobal)
if err == nil {
t.Error("should refuse to overwrite directory-layout skill")
}
}
func TestUpdateContentOverwritesExistingSkill(t *testing.T) {
home := t.TempDir()
st := New(Options{HomeDir: home, DisableBuiltins: true})
if _, err := st.CreateWithContent("editable", ScopeGlobal, "---\ndescription: v1\n---\nold body"); err != nil {
t.Fatalf("CreateWithContent: %v", err)
}
if err := st.UpdateContent("editable", ScopeGlobal, "---\ndescription: v2\n---\nnew body"); err != nil {
t.Fatalf("UpdateContent: %v", err)
}
sk, ok := st.Read("editable")
if !ok {
t.Fatal("skill missing after update")
}
if sk.Description != "v2" || sk.Body != "new body" {
t.Fatalf("update did not apply: description=%q body=%q", sk.Description, sk.Body)
}
}
func TestUpdateContentRefusesBuiltin(t *testing.T) {
st := New(Options{HomeDir: t.TempDir()})
if err := st.UpdateContent("explore", ScopeBuiltin, "---\ndescription: x\n---\nbody"); err == nil {
t.Error("updating a builtin should error")
}
}
func TestUpdateContentRefusesMissingSkill(t *testing.T) {
st := New(Options{HomeDir: t.TempDir(), DisableBuiltins: true})
if err := st.UpdateContent("does-not-exist", ScopeGlobal, "---\ndescription: x\n---\nbody"); err == nil {
t.Error("updating a nonexistent skill should error")
}
}
func TestUpdateContentRefusesScopeMismatch(t *testing.T) {
home := t.TempDir()
st := New(Options{HomeDir: home, DisableBuiltins: true})
if _, err := st.CreateWithContent("scoped2", ScopeGlobal, "---\ndescription: v1\n---\nbody"); err != nil {
t.Fatalf("CreateWithContent: %v", err)
}
if err := st.UpdateContent("scoped2", ScopeProject, "---\ndescription: v2\n---\nbody"); err == nil {
t.Error("updating with the wrong scope should error")
}
sk, ok := st.Read("scoped2")
if !ok || sk.Description != "v1" {
t.Fatalf("skill should be unchanged after a refused scope-mismatched update, got description=%q ok=%v", sk.Description, ok)
}
}
func TestUpdateContentRefusesSymlinkedFlatSkill(t *testing.T) {
home := t.TempDir()
outside := filepath.Join(t.TempDir(), "outside.md")
original := "---\ndescription: outside\nrunAs: subagent\ninvocation: manual\n---\noriginal"
if err := os.WriteFile(outside, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
root := filepath.Join(home, ".reasonix", SkillsDirname)
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(root, "linked.md")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
st := New(Options{HomeDir: home, DisableBuiltins: true})
if _, ok := st.Read("linked"); !ok {
t.Fatal("symlinked flat skill should remain readable")
}
if err := st.UpdateContent("linked", ScopeGlobal, "changed"); err == nil {
t.Fatal("updating a symlinked flat skill should fail")
}
got, err := os.ReadFile(outside)
if err != nil || string(got) != original {
t.Fatalf("outside target changed: content=%q err=%v", got, err)
}
}
func TestUpdateContentRefusesSymlinkedDirectorySkill(t *testing.T) {
home := t.TempDir()
outsideDir := t.TempDir()
outside := filepath.Join(outsideDir, SkillFile)
original := "---\ndescription: outside\nrunAs: subagent\ninvocation: manual\n---\noriginal"
if err := os.WriteFile(outside, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
root := filepath.Join(home, ".reasonix", SkillsDirname)
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outsideDir, filepath.Join(root, "linked-dir")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
st := New(Options{HomeDir: home, DisableBuiltins: true})
if _, ok := st.Read("linked-dir"); !ok {
t.Fatal("symlinked directory skill should remain readable")
}
if err := st.UpdateContent("linked-dir", ScopeGlobal, "changed"); err == nil {
t.Fatal("updating a symlinked directory skill should fail")
}
got, err := os.ReadFile(outside)
if err != nil || string(got) != original {
t.Fatalf("outside target changed: content=%q err=%v", got, err)
}
}
func TestDeleteSymlinkedSkillsRemovesLinksNotTargets(t *testing.T) {
home := t.TempDir()
root := filepath.Join(home, ".reasonix", SkillsDirname)
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
outsideDir := t.TempDir()
flatTarget := filepath.Join(outsideDir, "flat-target.md")
dirTarget := filepath.Join(outsideDir, "directory-target")
if err := os.MkdirAll(dirTarget, 0o755); err != nil {
t.Fatal(err)
}
content := []byte("---\ndescription: linked\nrunAs: subagent\ninvocation: manual\n---\nbody")
if err := os.WriteFile(flatTarget, content, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dirTarget, SkillFile), content, 0o644); err != nil {
t.Fatal(err)
}
flatLink := filepath.Join(root, "flat-link.md")
dirLink := filepath.Join(root, "dir-link")
if err := os.Symlink(flatTarget, flatLink); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
if err := os.Symlink(dirTarget, dirLink); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
st := New(Options{HomeDir: home, DisableBuiltins: true})
for _, name := range []string{"flat-link", "dir-link"} {
if err := st.Delete(name, ScopeGlobal); err != nil {
t.Fatalf("Delete(%q): %v", name, err)
}
}
if _, err := os.Lstat(flatLink); !os.IsNotExist(err) {
t.Fatalf("flat link still exists: %v", err)
}
if _, err := os.Lstat(dirLink); !os.IsNotExist(err) {
t.Fatalf("directory link still exists: %v", err)
}
if got, err := os.ReadFile(flatTarget); err != nil || string(got) != string(content) {
t.Fatalf("flat target changed: content=%q err=%v", got, err)
}
if got, err := os.ReadFile(filepath.Join(dirTarget, SkillFile)); err != nil || string(got) != string(content) {
t.Fatalf("directory target changed: content=%q err=%v", got, err)
}
}
func TestDeleteRemovesDirectoryLayoutSkill(t *testing.T) {
home := t.TempDir()
st := New(Options{HomeDir: home, DisableBuiltins: true})
path, err := st.CreateWithContent("throwaway", ScopeGlobal, "---\ndescription: x\n---\nbody")
if err != nil {
t.Fatalf("CreateWithContent: %v", err)
}
if err := st.Delete("throwaway", ScopeGlobal); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, ok := st.Read("throwaway"); ok {
t.Fatal("skill should be gone after Delete")
}
if _, err := os.Stat(filepath.Dir(path)); !os.IsNotExist(err) {
t.Fatalf("skill directory should be removed, stat err=%v", err)
}
}
func TestDeleteRefusesBuiltin(t *testing.T) {
st := New(Options{HomeDir: t.TempDir()})
if err := st.Delete("explore", ScopeBuiltin); err == nil {
t.Error("deleting a builtin should error")
}
}
func TestDeleteRefusesMissingSkill(t *testing.T) {
st := New(Options{HomeDir: t.TempDir(), DisableBuiltins: true})
if err := st.Delete("does-not-exist", ScopeGlobal); err == nil {
t.Error("deleting a nonexistent skill should error")
}
}
func TestDeleteRefusesScopeMismatch(t *testing.T) {
home := t.TempDir()
st := New(Options{HomeDir: home, DisableBuiltins: true})
if _, err := st.CreateWithContent("scoped", ScopeGlobal, "---\ndescription: x\n---\nbody"); err != nil {
t.Fatalf("CreateWithContent: %v", err)
}
// The skill actually lives at ScopeGlobal; a ScopeProject delete request
// for the same name must refuse rather than silently no-op or, worse,
// resolve to an unrelated file.
if err := st.Delete("scoped", ScopeProject); err == nil {
t.Error("deleting with the wrong scope should error")
}
if _, ok := st.Read("scoped"); !ok {
t.Fatal("skill should survive a refused scope-mismatched delete")
}
}
// --- New edge cases ---
func TestNewWithCustomPaths(t *testing.T) {
custom := t.TempDir()
st := New(Options{HomeDir: t.TempDir(), CustomPaths: []string{custom}, DisableBuiltins: true})
roots := st.Roots()
found := false
for _, r := range roots {
if r.Dir == custom && r.Scope == ScopeCustom {
found = true
break
}
}
if !found {
t.Error("custom path not in roots")
}
}
func TestHasProjectScope(t *testing.T) {
st1 := New(Options{HomeDir: t.TempDir(), ProjectRoot: "/some/project"})
if !st1.HasProjectScope() {
t.Error("with project root should return true")
}
st2 := New(Options{HomeDir: t.TempDir()})
if st2.HasProjectScope() {
t.Error("without project root should return false")
}
}