package api import ( "bytes" "encoding/json" "io" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "github.com/sipeed/picoclaw/pkg/config" ) func TestHandleListSkills(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } workspace := filepath.Join(t.TempDir(), "workspace") cfg.Agents.Defaults.Workspace = workspace err = config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } if err := os.MkdirAll(filepath.Join(workspace, "skills", "workspace-skill"), 0o755); err != nil { t.Fatalf("MkdirAll(workspace skill) error = %v", err) } if err := os.WriteFile( filepath.Join(workspace, "skills", "workspace-skill", "SKILL.md"), []byte("---\nname: workspace-skill\ndescription: Workspace skill\n---\n"), 0o644, ); err != nil { t.Fatalf("WriteFile(workspace skill) error = %v", err) } globalSkillDir := filepath.Join(globalConfigDir(), "skills", "global-skill") if err := os.MkdirAll(globalSkillDir, 0o755); err != nil { t.Fatalf("MkdirAll(global skill) error = %v", err) } if err := os.WriteFile( filepath.Join(globalSkillDir, "SKILL.md"), []byte("---\nname: global-skill\ndescription: Global skill\n---\n"), 0o644, ); err != nil { t.Fatalf("WriteFile(global skill) error = %v", err) } builtinRoot := filepath.Join(t.TempDir(), "builtin-skills") oldBuiltin := os.Getenv("PICOCLAW_BUILTIN_SKILLS") if err := os.Setenv("PICOCLAW_BUILTIN_SKILLS", builtinRoot); err != nil { t.Fatalf("Setenv(PICOCLAW_BUILTIN_SKILLS) error = %v", err) } defer func() { if oldBuiltin == "" { _ = os.Unsetenv("PICOCLAW_BUILTIN_SKILLS") } else { _ = os.Setenv("PICOCLAW_BUILTIN_SKILLS", oldBuiltin) } }() builtinSkillDir := filepath.Join(builtinRoot, "builtin-skill") if err := os.MkdirAll(builtinSkillDir, 0o755); err != nil { t.Fatalf("MkdirAll(builtin skill) error = %v", err) } if err := os.WriteFile( filepath.Join(builtinSkillDir, "SKILL.md"), []byte("---\nname: builtin-skill\ndescription: Builtin skill\n---\n"), 0o644, ); err != nil { t.Fatalf("WriteFile(builtin skill) error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/skills", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp skillSupportResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(resp.Skills) != 3 { t.Fatalf("skills count = %d, want 3", len(resp.Skills)) } gotSkills := make(map[string]string, len(resp.Skills)) for _, skill := range resp.Skills { gotSkills[skill.Name] = skill.Source } if gotSkills["workspace-skill"] != "workspace" { t.Fatalf("workspace-skill source = %q, want workspace", gotSkills["workspace-skill"]) } if gotSkills["global-skill"] != "global" { t.Fatalf("global-skill source = %q, want global", gotSkills["global-skill"]) } if gotSkills["builtin-skill"] != "builtin" { t.Fatalf("builtin-skill source = %q, want builtin", gotSkills["builtin-skill"]) } } func TestHandleGetSkill(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } workspace := filepath.Join(t.TempDir(), "workspace") cfg.Agents.Defaults.Workspace = workspace err = config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } skillDir := filepath.Join(workspace, "skills", "viewer-skill") if err := os.MkdirAll(skillDir, 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } if err := os.WriteFile( filepath.Join(skillDir, "SKILL.md"), []byte( "---\nname: viewer-skill\ndescription: Viewable skill\n---\n# Viewer Skill\n\nThis is visible content.\n", ), 0o644, ); err != nil { t.Fatalf("WriteFile() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/skills/viewer-skill", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp skillDetailResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if resp.Name != "viewer-skill" || resp.Source != "workspace" || resp.Description != "Viewable skill" { t.Fatalf("unexpected response: %#v", resp) } if resp.Content != "# Viewer Skill\n\nThis is visible content.\n" { t.Fatalf("content = %q", resp.Content) } } func TestHandleGetSkillUsesResolvedPath(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } workspace := filepath.Join(t.TempDir(), "workspace") cfg.Agents.Defaults.Workspace = workspace err = config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } skillDir := filepath.Join(workspace, "skills", "folder-name") if err := os.MkdirAll(skillDir, 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } if err := os.WriteFile( filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: display-name\ndescription: Mismatched path skill\n---\n# Display Name\n"), 0o644, ); err != nil { t.Fatalf("WriteFile() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/skills/display-name", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp skillDetailResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if resp.Name != "display-name" { t.Fatalf("resp.Name = %q, want display-name", resp.Name) } if resp.Content != "# Display Name\n" { t.Fatalf("content = %q", resp.Content) } } func TestHandleImportSkill(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } workspace := filepath.Join(t.TempDir(), "workspace") cfg.Agents.Defaults.Workspace = workspace err = config.SaveConfig(configPath, cfg) if err != nil { t.Fatalf("SaveConfig() error = %v", err) } var body bytes.Buffer writer := multipart.NewWriter(&body) part, err := writer.CreateFormFile("file", "Plain Skill.md") if err != nil { t.Fatalf("CreateFormFile() error = %v", err) } _, err = io.WriteString(part, "# Plain Skill\n\nUse this skill to test imports.\n") if err != nil { t.Fatalf("WriteString() error = %v", err) } err = writer.Close() if err != nil { t.Fatalf("Close() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &body) req.Header.Set("Content-Type", writer.FormDataContentType()) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } skillFile := filepath.Join(workspace, "skills", "plain-skill", "SKILL.md") content, err := os.ReadFile(skillFile) if err != nil { t.Fatalf("ReadFile() error = %v", err) } expected := "---\nname: plain-skill\ndescription: Plain Skill\n---\n\n# Plain Skill\n\nUse this skill to test imports.\n" if string(content) != expected { t.Fatalf("saved skill content mismatch:\n%s", string(content)) } rec2 := httptest.NewRecorder() req2 := httptest.NewRequest(http.MethodGet, "/api/skills", nil) mux.ServeHTTP(rec2, req2) if rec2.Code != http.StatusOK { t.Fatalf("list status = %d, want %d, body=%s", rec2.Code, http.StatusOK, rec2.Body.String()) } var listResp skillSupportResponse if err := json.Unmarshal(rec2.Body.Bytes(), &listResp); err != nil { t.Fatalf("Unmarshal list response error = %v", err) } found := false for _, skill := range listResp.Skills { if skill.Name == "plain-skill" && skill.Source == "workspace" && skill.Description == "Plain Skill" { found = true } } if !found { t.Fatalf("plain-skill should be listed after import, got %#v", listResp.Skills) } } func TestHandleDeleteSkill(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } workspace := filepath.Join(t.TempDir(), "workspace") cfg.Agents.Defaults.Workspace = workspace if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } skillDir := filepath.Join(workspace, "skills", "delete-me") if err := os.MkdirAll(skillDir, 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } if err := os.WriteFile( filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: delete-me\ndescription: delete me\n---\n"), 0o644, ); err != nil { t.Fatalf("WriteFile() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodDelete, "/api/skills/delete-me", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if _, err := os.Stat(skillDir); !os.IsNotExist(err) { t.Fatalf("skill directory should be removed, stat err=%v", err) } }