Compare commits

..

3 Commits

Author SHA1 Message Date
fullstack-engineer 89904a531e test(handlers): add coverage for plugin listing endpoints and helpers
Adds plugins_listing_test.go — 9 new tests covering:
- ListRegistry: all plugins returned, empty dir, runtime filter, unspecified
  runtimes included for all, manifest fields parsed, no-manifest dir fallback
- ListAvailableForWorkspace: runtime filter via runtimeLookup, unspecified
  runtimes included
- HTTP status: both endpoints return 200

Issue: #1815 follow-up — plugins_listing.go was at 0% coverage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 09:24:51 +00:00
fullstack-engineer 5564b394fe test: add coverage for PatchAbilities, BroadcastHandler, ListSources, and cssVar
Go (3 files, 23 tests):
  workspace_abilities_test.go (9): PatchAbilities — 400 (invalid UUID /
    empty body / invalid JSON), 404 (workspace not found / DB error),
    200 (update each ability independently and both together)

  workspace_broadcast_test.go (13): BroadcastHandler + broadcastTruncate —
    400 (invalid UUID / missing message), 404 (not found), 403 (disabled
    with hint), 500 (recipient query error), 200 (no recipients / one
    recipient / recipient insert fails / sender log fails)

  plugins_sources_test.go (1): ListSources — returns 200 with schemes
    array from the real plugin registry, stable across calls

Canvas (1 file, 4 tests):
  theme.test.ts: cssVar — all 23 ColorToken variants, purity,
    hyphenated tokens, style-prop usability

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 09:12:00 +00:00
fullstack-engineer 8b03185cb2 test(canvas): add explicit STATUS_CONFIG shape coverage
STATUS_CONFIG exports 7 status keys (online, offline, paused, degraded,
failed, provisioning, not_configured) with dot/glow/label/bar per entry.
The existing statusDotClass.test.ts covered .dot indirectly but left the
constant's full shape (label, glow, bar) untested. Add a dedicated
design-tokens.test.ts that asserts all keys exist, every entry has the
correct fields, and field values match the known tailwind tokens.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 09:12:00 +00:00
@@ -0,0 +1,287 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
)
// setupTestPlugins creates a temporary plugins directory with named
// subdirectories, each optionally containing a plugin.yaml manifest.
func setupTestPlugins(t *testing.T, plugins map[string]string /* name → yamlContents */) string {
t.Helper()
dir := t.TempDir()
for name, yaml := range plugins {
plugDir := filepath.Join(dir, name)
if err := os.MkdirAll(plugDir, 0755); err != nil {
t.Fatalf("setupTestPlugins: mkdir %s: %v", plugDir, err)
}
if yaml != "" {
if err := os.WriteFile(filepath.Join(plugDir, "plugin.yaml"), []byte(yaml), 0644); err != nil {
t.Fatalf("setupTestPlugins: write plugin.yaml for %s: %v", name, err)
}
}
}
return dir
}
// makeTestHandler creates a PluginsHandler wired with a stub runtimeLookup
// that always returns "claude-code".
func makeTestHandler(t *testing.T, pluginsDir string) *PluginsHandler {
t.Helper()
h := NewPluginsHandler(pluginsDir, nil, nil)
h.WithRuntimeLookup(func(workspaceID string) (string, error) {
return "claude-code", nil
})
return h
}
// listRegistry fires ListRegistry and returns the parsed []pluginInfo.
func listRegistry(h *PluginsHandler, runtime string) []pluginInfo {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins", nil)
if runtime != "" {
c.Request.URL.RawQuery = "runtime=" + runtime
}
h.ListRegistry(c)
var out []pluginInfo
json.Unmarshal(w.Body.Bytes(), &out)
return out
}
// listAvailable fires ListAvailableForWorkspace and returns the parsed []pluginInfo.
func listAvailable(h *PluginsHandler, workspaceID string) []pluginInfo {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/workspaces/"+workspaceID+"/plugins/available", nil)
c.Params = []gin.Param{{Key: "id", Value: workspaceID}}
h.ListAvailableForWorkspace(c)
var out []pluginInfo
json.Unmarshal(w.Body.Bytes(), &out)
return out
}
// --- ListRegistry ---
func TestListRegistry_ReturnsAllPlugins(t *testing.T) {
pluginsDir := setupTestPlugins(t, map[string]string{
"plugin-a": `name: plugin-a`,
"plugin-b": `name: plugin-b`,
})
h := makeTestHandler(t, pluginsDir)
plugins := listRegistry(h, "")
if len(plugins) != 2 {
t.Fatalf("expected 2 plugins, got %d", len(plugins))
}
names := make(map[string]bool)
for _, p := range plugins {
names[p.Name] = true
}
if !names["plugin-a"] || !names["plugin-b"] {
t.Errorf("unexpected plugin names: %v", names)
}
}
func TestListRegistry_EmptyDirectory(t *testing.T) {
dir := t.TempDir()
h := makeTestHandler(t, dir)
plugins := listRegistry(h, "")
if len(plugins) != 0 {
t.Fatalf("expected 0 plugins for empty dir, got %d", len(plugins))
}
}
func TestListRegistry_FiltersByRuntime(t *testing.T) {
// plugin-a supports claude-code; plugin-b supports langgraph only.
pluginsDir := setupTestPlugins(t, map[string]string{
"plugin-a": `
name: plugin-a
runtimes:
- claude_code
`,
"plugin-b": `
name: plugin-b
runtimes:
- langgraph
`,
})
h := makeTestHandler(t, pluginsDir)
// Filter by claude-code — only plugin-a matches.
plugins := listRegistry(h, "claude-code")
if len(plugins) != 1 || plugins[0].Name != "plugin-a" {
t.Errorf("expected [plugin-a], got %v", plugins)
}
// Filter by langgraph — only plugin-b matches.
plugins = listRegistry(h, "langgraph")
if len(plugins) != 1 || plugins[0].Name != "plugin-b" {
t.Errorf("expected [plugin-b], got %v", plugins)
}
// Filter by unknown runtime — plugins that declare specific runtimes
// are excluded; only unspecified (empty Runtimes) would appear.
plugins = listRegistry(h, "autogen")
if len(plugins) != 0 {
t.Errorf("expected 0 for unknown runtime, got %v", plugins)
}
}
func TestListRegistry_UnspecifiedRuntimesIncludedForAll(t *testing.T) {
// A plugin with no runtimes field is treated as "unspecified" — included
// for every runtime filter.
pluginsDir := setupTestPlugins(t, map[string]string{
"generic-plugin": `name: generic-plugin`,
"claude-plugin": `
name: claude-plugin
runtimes:
- claude_code
`,
})
h := makeTestHandler(t, pluginsDir)
plugins := listRegistry(h, "claude-code")
names := make(map[string]bool)
for _, p := range plugins {
names[p.Name] = true
}
// Both should appear: generic-plugin (unspecified) + claude-plugin.
if !names["generic-plugin"] || !names["claude-plugin"] {
t.Errorf("expected both plugins, got %v", names)
}
}
func TestListRegistry_ManifestFieldsParsed(t *testing.T) {
// Verify that ListRegistry returns the full pluginInfo shape from plugin.yaml.
pluginsDir := setupTestPlugins(t, map[string]string{
"my-plugin": `
name: my-plugin
version: 1.2.3
description: A test plugin
author: Test Author
tags:
- testing
- demo
skills:
- tdd-loop
- code-review
runtimes:
- claude_code
`,
})
h := makeTestHandler(t, pluginsDir)
plugins := listRegistry(h, "")
if len(plugins) != 1 {
t.Fatalf("expected 1 plugin, got %d", len(plugins))
}
p := plugins[0]
if p.Name != "my-plugin" {
t.Errorf("Name = %q; want my-plugin", p.Name)
}
if p.Version != "1.2.3" {
t.Errorf("Version = %q; want 1.2.3", p.Version)
}
if p.Description != "A test plugin" {
t.Errorf("Description = %q; want 'A test plugin'", p.Description)
}
if p.Author != "Test Author" {
t.Errorf("Author = %q; want 'Test Author'", p.Author)
}
if len(p.Tags) != 2 {
t.Errorf("Tags = %v; want 2 entries", p.Tags)
}
if len(p.Skills) != 2 {
t.Errorf("Skills = %v; want 2 entries", p.Skills)
}
if len(p.Runtimes) != 1 {
t.Errorf("Runtimes = %v; want 1 entry", p.Runtimes)
}
}
func TestListRegistry_NoManifestFile(t *testing.T) {
// A plugin directory without plugin.yaml should still appear with its
// directory name as the Name.
pluginsDir := setupTestPlugins(t, map[string]string{
"no-manifest-plugin": ``, // directory with no plugin.yaml
})
h := makeTestHandler(t, pluginsDir)
plugins := listRegistry(h, "")
if len(plugins) != 1 {
t.Fatalf("expected 1 plugin, got %d", len(plugins))
}
if plugins[0].Name != "no-manifest-plugin" {
t.Errorf("expected Name to be directory name, got %q", plugins[0].Name)
}
}
// --- ListAvailableForWorkspace ---
func TestListAvailableForWorkspace_WithRuntimeLookup(t *testing.T) {
// When runtimeLookup is wired, ListAvailableForWorkspace filters by the
// workspace's runtime (claude-code), which is set by makeTestHandler.
pluginsDir := setupTestPlugins(t, map[string]string{
"runtime-plugin": `
name: runtime-plugin
runtimes:
- claude_code
`,
"langgraph-plugin": `
name: langgraph-plugin
runtimes:
- langgraph
`,
})
h := makeTestHandler(t, pluginsDir)
plugins := listAvailable(h, "ws-123")
if len(plugins) != 1 || plugins[0].Name != "runtime-plugin" {
t.Errorf("expected [runtime-plugin], got %v", plugins)
}
}
func TestListAvailableForWorkspace_UnspecifiedRuntime(t *testing.T) {
// A plugin without runtimes is included for all workspaces.
pluginsDir := setupTestPlugins(t, map[string]string{
"generic": `name: generic`,
})
h := makeTestHandler(t, pluginsDir)
plugins := listAvailable(h, "ws-456")
if len(plugins) != 1 || plugins[0].Name != "generic" {
t.Errorf("expected [generic], got %v", plugins)
}
}
// --- HTTP response codes ---
func TestListRegistry_HTTPStatusOK(t *testing.T) {
pluginsDir := setupTestPlugins(t, map[string]string{"foo": "name: foo"})
h := NewPluginsHandler(pluginsDir, nil, nil)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins", nil)
h.ListRegistry(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
func TestListAvailableForWorkspace_HTTPStatusOK(t *testing.T) {
pluginsDir := setupTestPlugins(t, map[string]string{"bar": "name: bar"})
h := makeTestHandler(t, pluginsDir)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/workspaces/ws-789/plugins/available", nil)
c.Params = []gin.Param{{Key: "id", Value: "ws-789"}}
h.ListAvailableForWorkspace(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}