mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 08:18:19 +00:00
251 lines
10 KiB
Go
251 lines
10 KiB
Go
package skills
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
type customSlugConflictStore struct {
|
|
customID uuid.UUID
|
|
nextVersion int
|
|
conflictErr error
|
|
updatedID uuid.UUID
|
|
updatedCtx context.Context
|
|
updates map[string]any
|
|
updateErr error
|
|
}
|
|
|
|
func (s *customSlugConflictStore) UpsertSystemSkill(context.Context, store.SkillCreateParams) (uuid.UUID, bool, string, error) {
|
|
return s.customID, false, "", s.conflictErr
|
|
}
|
|
|
|
func (s *customSlugConflictStore) GetNextVersion(context.Context, string) int { return s.nextVersion }
|
|
func (s *customSlugConflictStore) BumpVersion() {}
|
|
|
|
func (s *customSlugConflictStore) UpdateSkill(ctx context.Context, id uuid.UUID, updates map[string]any) error {
|
|
s.updatedID = id
|
|
s.updatedCtx = ctx
|
|
s.updates = updates
|
|
return s.updateErr
|
|
}
|
|
|
|
func (s *customSlugConflictStore) StoreMissingDeps(context.Context, uuid.UUID, []string) error {
|
|
return nil
|
|
}
|
|
|
|
func TestSeeder_RestoresCustomMetadataWhenBundledSlugConflicts(t *testing.T) {
|
|
bundledDir := filepath.Join(t.TempDir(), "bundled")
|
|
managedDir := filepath.Join(t.TempDir(), "managed")
|
|
slug := "lark-pm"
|
|
bundledContent := "---\nname: lark-pm\ndescription: Bundled workflow\nlicense: Proprietary. Part of GoClaw bundled skills.\n---\n"
|
|
customContent := "---\nname: Lark PM\ndescription: Custom Lark workflow\nversion: 1.0.0\n---\n\n# Custom Lark PM\n"
|
|
|
|
for path, content := range map[string]string{
|
|
filepath.Join(bundledDir, slug, "SKILL.md"): bundledContent,
|
|
filepath.Join(managedDir, slug, "4", "SKILL.md"): customContent,
|
|
filepath.Join(managedDir, slug, "5", "SKILL.md"): bundledContent,
|
|
} {
|
|
writeSeederSkillFile(t, path, content)
|
|
}
|
|
|
|
customID := uuid.New()
|
|
skillStore := &customSlugConflictStore{customID: customID, nextVersion: 5, conflictErr: store.ErrMisclassifiedCustomSkill}
|
|
seeder := NewSeeder(bundledDir, managedDir, skillStore)
|
|
seeded, skipped, _, err := seeder.Seed(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Seed error: %v", err)
|
|
}
|
|
if seeded != 0 || skipped != 1 {
|
|
t.Fatalf("Seed = (%d seeded, %d skipped), want (0, 1)", seeded, skipped)
|
|
}
|
|
if skillStore.updatedID != customID {
|
|
t.Fatalf("updated ID = %s, want %s", skillStore.updatedID, customID)
|
|
}
|
|
if tenantID := store.TenantIDFromContext(skillStore.updatedCtx); tenantID != store.MasterTenantID {
|
|
t.Fatalf("update tenant = %s, want master tenant %s", tenantID, store.MasterTenantID)
|
|
}
|
|
if got := skillStore.updates["name"]; got != "Lark PM" {
|
|
t.Fatalf("restored name = %q, want custom name", got)
|
|
}
|
|
if got := skillStore.updates["description"]; got != "Custom Lark workflow" {
|
|
t.Fatalf("restored description = %q, want custom description", got)
|
|
}
|
|
if got := skillStore.updates["file_path"]; got != filepath.Join(managedDir, slug, "4") {
|
|
t.Fatalf("restored file path = %q, want custom version directory", got)
|
|
}
|
|
if got := skillStore.updates["visibility"]; got != "private" {
|
|
t.Fatalf("restored visibility = %q, want private", got)
|
|
}
|
|
if got := skillStore.updates["status"]; got != "archived" {
|
|
t.Fatalf("restored status = %q, want archived", got)
|
|
}
|
|
if got := skillStore.updates["version"]; got != 4 {
|
|
t.Fatalf("restored version = %v, want 4", got)
|
|
}
|
|
|
|
var frontmatter map[string]string
|
|
if err := json.Unmarshal(skillStore.updates["frontmatter"].([]byte), &frontmatter); err != nil {
|
|
t.Fatalf("unmarshal restored frontmatter: %v", err)
|
|
}
|
|
if frontmatter["name"] != "Lark PM" || frontmatter["description"] != "Custom Lark workflow" {
|
|
t.Fatalf("restored frontmatter = %#v", frontmatter)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(managedDir, slug, "5")); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("overwritten numeric directory still exists: %v", err)
|
|
}
|
|
quarantineDir := filepath.Join(managedDir, slug, ".goclaw-bundled-5-"+hashSeederContent(bundledContent)[:12])
|
|
if _, err := os.Stat(filepath.Join(quarantineDir, "SKILL.md")); err != nil {
|
|
t.Fatalf("quarantined bundled skill missing: %v", err)
|
|
}
|
|
loader := NewLoader("", "", "")
|
|
if version, dir := loader.findLatestVersion(managedDir, slug); version != 4 || dir != filepath.Join(managedDir, slug, "4") {
|
|
t.Fatalf("latest managed version = (%d, %q), want restored custom version 4", version, dir)
|
|
}
|
|
}
|
|
|
|
func TestSeeder_ReportsAmbiguousCustomRecovery(t *testing.T) {
|
|
bundledDir := filepath.Join(t.TempDir(), "bundled")
|
|
managedDir := filepath.Join(t.TempDir(), "managed")
|
|
slug := "lark-pm"
|
|
bundledContent := "---\nname: lark-pm\ndescription: Bundled workflow\nlicense: Proprietary. Part of GoClaw bundled skills.\n---\n"
|
|
priorBundledContent := bundledContent + "\n# Previous bundled body\n"
|
|
writeSeederSkillFile(t, filepath.Join(bundledDir, slug, "SKILL.md"), bundledContent)
|
|
writeSeederSkillFile(t, filepath.Join(managedDir, slug, "4", "SKILL.md"), priorBundledContent)
|
|
writeSeederSkillFile(t, filepath.Join(managedDir, slug, "5", "SKILL.md"), bundledContent)
|
|
|
|
skillStore := &customSlugConflictStore{customID: uuid.New(), nextVersion: 5, conflictErr: store.ErrMisclassifiedCustomSkill}
|
|
seeder := NewSeeder(bundledDir, managedDir, skillStore)
|
|
_, skipped, _, err := seeder.Seed(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), "custom version is ambiguous") {
|
|
t.Fatalf("Seed error = %v, want ambiguous recovery error", err)
|
|
}
|
|
if skipped != 1 {
|
|
t.Fatalf("skipped = %d, want 1", skipped)
|
|
}
|
|
if skillStore.updatedID != uuid.Nil {
|
|
t.Fatalf("ambiguous recovery updated skill %s", skillStore.updatedID)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(managedDir, slug, "5", "SKILL.md")); err != nil {
|
|
t.Fatalf("ambiguous bundled version was moved: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSeeder_SkipsLegitimateCustomSlugConflictWithoutRecovery(t *testing.T) {
|
|
bundledDir := filepath.Join(t.TempDir(), "bundled")
|
|
managedDir := filepath.Join(t.TempDir(), "managed")
|
|
slug := "lark-pm"
|
|
writeSeederSkillFile(t, filepath.Join(bundledDir, slug, "SKILL.md"), "---\nname: lark-pm\ndescription: Bundled workflow\n---\n")
|
|
|
|
skillStore := &customSlugConflictStore{customID: uuid.New(), nextVersion: 2, conflictErr: store.ErrSystemSkillSlugConflict}
|
|
seeder := NewSeeder(bundledDir, managedDir, skillStore)
|
|
_, skipped, _, err := seeder.Seed(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Seed error: %v", err)
|
|
}
|
|
if skipped != 1 {
|
|
t.Fatalf("skipped = %d, want 1", skipped)
|
|
}
|
|
if skillStore.updatedID != uuid.Nil {
|
|
t.Fatalf("legitimate custom collision updated skill %s", skillStore.updatedID)
|
|
}
|
|
}
|
|
|
|
func TestSeeder_ReportsMissingMarkedRecoveryFiles(t *testing.T) {
|
|
bundledDir := filepath.Join(t.TempDir(), "bundled")
|
|
managedDir := filepath.Join(t.TempDir(), "managed")
|
|
slug := "lark-pm"
|
|
writeSeederSkillFile(t, filepath.Join(bundledDir, slug, "SKILL.md"), "---\nname: lark-pm\ndescription: Bundled workflow\n---\n")
|
|
|
|
skillStore := &customSlugConflictStore{
|
|
customID: uuid.New(),
|
|
nextVersion: 5,
|
|
conflictErr: store.ErrMisclassifiedCustomSkill,
|
|
}
|
|
seeder := NewSeeder(bundledDir, managedDir, skillStore)
|
|
_, skipped, _, err := seeder.Seed(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), "read overwritten bundled skill") {
|
|
t.Fatalf("Seed error = %v, want missing recovery file error", err)
|
|
}
|
|
if skipped != 1 {
|
|
t.Fatalf("skipped = %d, want 1", skipped)
|
|
}
|
|
}
|
|
|
|
func TestSeeder_ContinuesRecoveryFromQuarantinedBundledVersion(t *testing.T) {
|
|
bundledDir := filepath.Join(t.TempDir(), "bundled")
|
|
managedDir := filepath.Join(t.TempDir(), "managed")
|
|
slug := "lark-pm"
|
|
bundledContent := "---\nname: lark-pm\ndescription: Bundled workflow\n---\n"
|
|
writeSeederSkillFile(t, filepath.Join(bundledDir, slug, "SKILL.md"), bundledContent)
|
|
writeSeederSkillFile(t, filepath.Join(managedDir, slug, "4", "SKILL.md"), "---\nname: Lark PM\ndescription: Custom workflow\nversion: 1.0.0\n---\n")
|
|
quarantineDir := filepath.Join(managedDir, slug, ".goclaw-bundled-5-"+hashSeederContent(bundledContent)[:12])
|
|
writeSeederSkillFile(t, filepath.Join(quarantineDir, "SKILL.md"), bundledContent)
|
|
|
|
skillStore := &customSlugConflictStore{
|
|
customID: uuid.New(),
|
|
nextVersion: 5,
|
|
conflictErr: store.ErrMisclassifiedCustomSkill,
|
|
}
|
|
seeder := NewSeeder(bundledDir, managedDir, skillStore)
|
|
_, _, _, err := seeder.Seed(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Seed error: %v", err)
|
|
}
|
|
if skillStore.updatedID == uuid.Nil {
|
|
t.Fatal("quarantined recovery did not update custom metadata")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(quarantineDir, "SKILL.md")); err != nil {
|
|
t.Fatalf("quarantine missing after recovery: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSeeder_PropagatesCustomRecoveryUpdateFailure(t *testing.T) {
|
|
bundledDir := filepath.Join(t.TempDir(), "bundled")
|
|
managedDir := filepath.Join(t.TempDir(), "managed")
|
|
slug := "lark-pm"
|
|
bundledContent := "---\nname: lark-pm\ndescription: Bundled workflow\n---\n"
|
|
writeSeederSkillFile(t, filepath.Join(bundledDir, slug, "SKILL.md"), bundledContent)
|
|
writeSeederSkillFile(t, filepath.Join(managedDir, slug, "4", "SKILL.md"), "---\nname: Lark PM\ndescription: Custom workflow\nversion: 1.0.0\n---\n")
|
|
writeSeederSkillFile(t, filepath.Join(managedDir, slug, "5", "SKILL.md"), bundledContent)
|
|
|
|
skillStore := &customSlugConflictStore{
|
|
customID: uuid.New(),
|
|
nextVersion: 5,
|
|
conflictErr: store.ErrMisclassifiedCustomSkill,
|
|
updateErr: errors.New("database unavailable"),
|
|
}
|
|
seeder := NewSeeder(bundledDir, managedDir, skillStore)
|
|
_, _, _, err := seeder.Seed(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), "database unavailable") {
|
|
t.Fatalf("Seed error = %v, want update failure", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(managedDir, slug, "5", "SKILL.md")); err != nil {
|
|
t.Fatalf("bundled version moved despite failed update: %v", err)
|
|
}
|
|
}
|
|
|
|
func writeSeederSkillFile(t *testing.T, path, content string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
t.Fatalf("create skill directory: %v", err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatalf("write skill: %v", err)
|
|
}
|
|
}
|
|
|
|
func hashSeederContent(content string) string {
|
|
return fmt.Sprintf("%x", sha256.Sum256([]byte(content)))
|
|
}
|