Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01a9226ea0 | |||
| 41ad76b41c |
@@ -0,0 +1,183 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Behavioral coverage for the SkillsTab registry loading and source schemes
|
||||
// flows. Two regressions this pins down:
|
||||
//
|
||||
// 1. Registry fetch timeout: when GET /plugins takes >10s the component
|
||||
// used to silently swallow the error (console.warn only), making it
|
||||
// indistinguishable from a genuinely empty registry. Now it surfaces
|
||||
// a specific timeout error with a Retry button so the user can recover.
|
||||
//
|
||||
// 2. Source schemes fallback: GET /plugins/sources failure is silent
|
||||
// (falls back to "local only" UX) rather than crashing the component.
|
||||
// This test verifies the fallback works without breaking the rest of
|
||||
// the UI.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent, act, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
vi.fn((selector: (s: Record<string, unknown>) => unknown) =>
|
||||
selector({ setPanelTab: vi.fn() } as Record<string, unknown>),
|
||||
),
|
||||
{ getState: () => ({ setPanelTab: vi.fn() }) },
|
||||
),
|
||||
summarizeWorkspaceCapabilities: vi.fn(() => ({ skills: [], tools: [] })),
|
||||
}));
|
||||
|
||||
vi.mock("../Toaster", () => ({ showToast: vi.fn() }));
|
||||
|
||||
import { SkillsTab } from "../tabs/SkillsTab";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
function makeData() {
|
||||
return {
|
||||
name: "Test WS",
|
||||
status: "online",
|
||||
tier: 1,
|
||||
agentCard: null,
|
||||
activeTasks: 0,
|
||||
collapsed: false,
|
||||
role: "agent",
|
||||
lastErrorRate: 0,
|
||||
lastSampleError: "",
|
||||
url: "http://localhost:9000",
|
||||
parentId: null,
|
||||
currentTask: "",
|
||||
runtime: "claude_code",
|
||||
needsRestart: false,
|
||||
budgetLimit: null,
|
||||
};
|
||||
}
|
||||
|
||||
const REGISTRY = [
|
||||
{
|
||||
name: "browser-automation",
|
||||
version: "1.1.0",
|
||||
description: "Browser automation + testing",
|
||||
author: "molecule",
|
||||
tags: ["browser", "playwright"],
|
||||
skills: [],
|
||||
runtimes: ["claude-code"],
|
||||
},
|
||||
];
|
||||
|
||||
// Stable spy reference so tests can re-configure mockImplementation
|
||||
// without re-creating the spy (avoids any restoration ordering issues).
|
||||
let getSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Restore previous spy before creating a new one so old implementations
|
||||
// don't leak between tests.
|
||||
if (getSpy) getSpy.mockRestore();
|
||||
getSpy = vi.spyOn(api, "get");
|
||||
// Polyfill: jsdom Element.scrollIntoView is undefined without a browser.
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
// Microtasks (promise rejections) need real timers to flush so React
|
||||
// state updates are visible to waitFor polling.
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ─── Registry-loading tests ──────────────────────────────────────────────────
|
||||
|
||||
describe("SkillsTab registry loading", () => {
|
||||
it("shows a generic error when GET /plugins fails", async () => {
|
||||
getSpy.mockImplementation((path: string) => {
|
||||
if (path === "/plugins") return Promise.reject(new Error("503 Service Unavailable"));
|
||||
if (path === `/workspaces/ws-1/plugins`) return Promise.resolve([]);
|
||||
if (path === "/plugins/sources") return Promise.resolve({ schemes: ["local://"] });
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
render(<SkillsTab workspaceId="ws-1" data={makeData() as never} />);
|
||||
|
||||
// Expand the registry section to see the error div (showRegistry starts false).
|
||||
// Note: auto-expand requires registry.length > 0, so it doesn't fire on errors.
|
||||
// Match compact-empty test pattern: wait for pill to settle before clicking.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Plugins \(none installed\)/i)).toBeTruthy();
|
||||
});
|
||||
const installBtn = screen.getByRole("button", { name: /\+ Install Plugin/i });
|
||||
fireEvent.click(installBtn);
|
||||
|
||||
// Wait for the error div to appear inside the expanded registry.
|
||||
await screen.findByText(/503 Service Unavailable/i);
|
||||
});
|
||||
|
||||
it("Retry button re-fetches the registry after a generic error", async () => {
|
||||
let attempt = 0;
|
||||
getSpy.mockImplementation((path: string) => {
|
||||
if (path === "/plugins") {
|
||||
attempt++;
|
||||
if (attempt === 1) return Promise.reject(new Error("server error"));
|
||||
return Promise.resolve(REGISTRY);
|
||||
}
|
||||
if (path === `/workspaces/ws-1/plugins`) return Promise.resolve([]);
|
||||
if (path === "/plugins/sources") return Promise.resolve({ schemes: ["local://"] });
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
render(<SkillsTab workspaceId="ws-1" data={makeData() as never} />);
|
||||
|
||||
// Expand the registry section to see the error div (showRegistry starts false).
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Plugins \(none installed\)/i)).toBeTruthy();
|
||||
});
|
||||
const installBtn = screen.getByRole("button", { name: /\+ Install Plugin/i });
|
||||
fireEvent.click(installBtn);
|
||||
|
||||
// Wait for error state to appear.
|
||||
await screen.findByText(/server error/i);
|
||||
|
||||
// Click Retry — force=true bypasses the in-flight gate so the
|
||||
// stranded promise from the first attempt is ignored.
|
||||
const retryBtn = await screen.findByRole("button", { name: /retry/i });
|
||||
fireEvent.click(retryBtn);
|
||||
|
||||
// After retry succeeds, registry plugins appear.
|
||||
await screen.findByText("browser-automation");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Source-schemes tests ────────────────────────────────────────────────────
|
||||
|
||||
describe("SkillsTab source schemes", () => {
|
||||
it("loads source schemes from GET /plugins/sources without crashing", async () => {
|
||||
getSpy.mockImplementation((path: string) => {
|
||||
if (path === "/plugins") return Promise.resolve(REGISTRY);
|
||||
if (path === `/workspaces/ws-1/plugins`) return Promise.resolve([]);
|
||||
if (path === "/plugins/sources") return Promise.resolve({ schemes: ["local://", "github://"] });
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
render(<SkillsTab workspaceId="ws-1" data={makeData() as never} />);
|
||||
|
||||
await screen.findByText("browser-automation");
|
||||
expect(getSpy).toHaveBeenCalledWith("/plugins/sources");
|
||||
});
|
||||
|
||||
it("gracefully falls back when GET /plugins/sources fails", async () => {
|
||||
// /plugins/sources rejects (non-fatal); /plugins and /workspaces/:id/plugins succeed.
|
||||
getSpy.mockImplementation((path: string) => {
|
||||
if (path === "/plugins") return Promise.resolve(REGISTRY);
|
||||
if (path === `/workspaces/ws-1/plugins`) return Promise.resolve([]);
|
||||
if (path === "/plugins/sources") return Promise.reject(new Error("server error"));
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
// Must not throw — the component catches this and falls back silently.
|
||||
expect(() => render(<SkillsTab workspaceId="ws-1" data={makeData() as never} />))
|
||||
.not.toThrow();
|
||||
|
||||
// The rest of the UI still works — registry loaded despite sources failure.
|
||||
await screen.findByText("browser-automation");
|
||||
});
|
||||
});
|
||||
@@ -214,7 +214,7 @@ func strDefault(m map[string]interface{}, key, fallback string) string {
|
||||
// inputs. Transient daemon errors are logged distinctly so triage doesn't
|
||||
// confuse a flaky daemon with a stopped container.
|
||||
func (h *PluginsHandler) findRunningContainer(ctx context.Context, workspaceID string) string {
|
||||
name, err := provisioner.RunningContainerName(ctx, h.docker, workspaceID)
|
||||
name, err := provisioner.RunningContainerNameFunc(ctx, h.docker, workspaceID)
|
||||
if err != nil {
|
||||
log.Printf("plugins: docker inspect transient error for %s: %v (treating as not-running for this request)", workspaceID, err)
|
||||
return ""
|
||||
|
||||
@@ -66,9 +66,11 @@ func TestFindRunningContainer_RoutesThroughProvisionerSSOT(t *testing.T) {
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
// Pkg.Func form: provisioner.RunningContainerName(...)
|
||||
// Pkg.Func form: provisioner.RunningContainerNameFunc(...)
|
||||
// Uses the pluggable wrapper, not the raw function — tests swap the
|
||||
// wrapper so ListInstalled can be tested without a real Docker client.
|
||||
if pkgIdent, ok := sel.X.(*ast.Ident); ok {
|
||||
if pkgIdent.Name == "provisioner" && sel.Sel.Name == "RunningContainerName" {
|
||||
if pkgIdent.Name == "provisioner" && sel.Sel.Name == "RunningContainerNameFunc" {
|
||||
callsRunningContainerName = true
|
||||
}
|
||||
}
|
||||
@@ -83,7 +85,7 @@ func TestFindRunningContainer_RoutesThroughProvisionerSSOT(t *testing.T) {
|
||||
|
||||
if !callsRunningContainerName {
|
||||
t.Errorf(
|
||||
"findRunningContainer must call provisioner.RunningContainerName for the SSOT inspect — see molecule-core#10. Found no such call.",
|
||||
"findRunningContainer must call provisioner.RunningContainerNameFunc for the SSOT inspect — see molecule-core#10. Found no such call.",
|
||||
)
|
||||
}
|
||||
if callsContainerInspectRaw {
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
package handlers
|
||||
|
||||
// plugins_listing_test.go — coverage for plugins_listing.go.
|
||||
//
|
||||
// Covered handlers:
|
||||
// - ListRegistry GET /plugins
|
||||
// - ListAvailableForWorkspace GET /workspaces/:id/plugins/available
|
||||
// - ListInstalled GET /workspaces/:id/plugins
|
||||
// - CheckRuntimeCompatibility GET /workspaces/:id/plugins/compatibility?runtime=<name>
|
||||
//
|
||||
// The Docker client is NOT mocked directly. Instead, the package-level
|
||||
// provisioner.RunningContainerNameFunc is swapped via provisioner.StubRunningContainerName.
|
||||
// This mirrors the existing stubInstallPluginViaEIC pattern and avoids needing
|
||||
// to implement the full docker.APIClient interface.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// stageRegistry creates a plugin dir under tmpDir with a plugin.yaml.
|
||||
func stageRegistry(t *testing.T, tmpDir, name, manifestYAML string) {
|
||||
t.Helper()
|
||||
plugDir := filepath.Join(tmpDir, name)
|
||||
if err := os.Mkdir(plugDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir plugin dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(plugDir, "plugin.yaml"), []byte(manifestYAML), 0644); err != nil {
|
||||
t.Fatalf("write plugin.yaml: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// stubContainerRunning stubs RunningContainerNameFunc so findRunningContainer
|
||||
// returns the given container name (empty string = container not running).
|
||||
func stubContainerRunning(t *testing.T, name string) {
|
||||
t.Helper()
|
||||
provisioner.StubRunningContainerName(t,
|
||||
func(ctx context.Context, cli *client.Client, workspaceID string) (string, error) {
|
||||
return name, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ─── ListRegistry ──────────────────────────────────────────────────────────────
|
||||
|
||||
func TestListRegistry_EmptyDir_ReturnsEmptyArray(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
h := NewPluginsHandler(tmpDir, nil, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/plugins", nil)
|
||||
|
||||
h.ListRegistry(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var plugins []any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if len(plugins) != 0 {
|
||||
t.Fatalf("expected empty array, got %d plugins", len(plugins))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistry_WithPlugins_ReturnsPluginInfo(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
stageRegistry(t, tmpDir, "browser-automation",
|
||||
"name: browser-automation\nversion: \"1.2.0\"\ndescription: Browser automation\nauthor: molecule\ntags:\n - browser\n - playwright\nskills:\n - automates-ui\nruntimes:\n - claude_code\n")
|
||||
h := NewPluginsHandler(tmpDir, nil, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/plugins", nil)
|
||||
|
||||
h.ListRegistry(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var plugins []map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if len(plugins) != 1 {
|
||||
t.Fatalf("expected 1 plugin, got %d", len(plugins))
|
||||
}
|
||||
if plugins[0]["name"] != "browser-automation" {
|
||||
t.Errorf("unexpected plugin: %v", plugins[0])
|
||||
}
|
||||
if plugins[0]["version"] != "1.2.0" {
|
||||
t.Errorf("expected version 1.2.0, got %v", plugins[0]["version"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistry_RuntimeFilter_IncludesUnspecifiedPlugin(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
// Plugin without runtimes field — treated as "unspecified, try it".
|
||||
stageRegistry(t, tmpDir, "generic-tool",
|
||||
"name: generic-tool\nversion: \"1.0.0\"\ndescription: Works everywhere\n")
|
||||
h := NewPluginsHandler(tmpDir, nil, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/plugins?runtime=claude_code", nil)
|
||||
|
||||
h.ListRegistry(c)
|
||||
|
||||
var plugins []map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if len(plugins) != 1 || plugins[0]["name"] != "generic-tool" {
|
||||
t.Errorf("expected [generic-tool], got %v", plugins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistry_RuntimeFilter_ExcludesIncompatiblePlugin(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
stageRegistry(t, tmpDir, "hermes-only",
|
||||
"name: hermes-only\nversion: \"1.0.0\"\nruntimes:\n - hermes\n")
|
||||
h := NewPluginsHandler(tmpDir, nil, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/plugins?runtime=claude_code", nil)
|
||||
|
||||
h.ListRegistry(c)
|
||||
|
||||
var plugins []map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if len(plugins) != 0 {
|
||||
t.Errorf("expected 0 plugins (hermes-only filtered out), got %d", len(plugins))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistry_RuntimeFilter_NormalizesHyphen(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
stageRegistry(t, tmpDir, "tool-x",
|
||||
"name: tool-x\nversion: \"1.0.0\"\nruntimes:\n - claude-code\n")
|
||||
h := NewPluginsHandler(tmpDir, nil, nil)
|
||||
|
||||
// Query uses "claude_code" (underscore); manifest uses "claude-code" (hyphen).
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/plugins?runtime=claude_code", nil)
|
||||
|
||||
h.ListRegistry(c)
|
||||
|
||||
var plugins []map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if len(plugins) != 1 {
|
||||
t.Errorf("expected 1 plugin (hyphen/underscore normalised), got %d", len(plugins))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistry_MultipleRuntimes_PluginIncludedForEach(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
stageRegistry(t, tmpDir, "multi-tool",
|
||||
"name: multi-tool\nversion: \"1.0.0\"\nruntimes:\n - claude_code\n - hermes\n")
|
||||
h := NewPluginsHandler(tmpDir, nil, nil)
|
||||
|
||||
for _, runtime := range []string{"claude_code", "hermes"} {
|
||||
t.Run(runtime, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/plugins?runtime="+runtime, nil)
|
||||
|
||||
h.ListRegistry(c)
|
||||
|
||||
var plugins []map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if len(plugins) != 1 {
|
||||
t.Errorf("expected 1 plugin for runtime %s, got %d", runtime, len(plugins))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ListAvailableForWorkspace ────────────────────────────────────────────────
|
||||
|
||||
func TestListAvailableForWorkspace_NoRuntimeLookup_ReturnsUnfiltered(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
stageRegistry(t, tmpDir, "tool-a", "name: tool-a\nruntimes:\n - claude_code\n")
|
||||
stageRegistry(t, tmpDir, "tool-b", "name: tool-b\nruntimes:\n - hermes\n")
|
||||
h := NewPluginsHandler(tmpDir, nil, nil)
|
||||
// No runtimeLookup → unfiltered registry
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-any"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-any/plugins/available", nil)
|
||||
|
||||
h.ListAvailableForWorkspace(c)
|
||||
|
||||
var plugins []map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if len(plugins) != 2 {
|
||||
t.Errorf("expected 2 plugins (unfiltered), got %d", len(plugins))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAvailableForWorkspace_WithRuntimeLookup_FiltersByRuntime(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
stageRegistry(t, tmpDir, "tool-claude", "name: tool-claude\nruntimes:\n - claude_code\n")
|
||||
stageRegistry(t, tmpDir, "tool-hermes", "name: tool-hermes\nruntimes:\n - hermes\n")
|
||||
h := NewPluginsHandler(tmpDir, nil, nil).WithRuntimeLookup(
|
||||
func(workspaceID string) (string, error) { return "claude_code", nil },
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-claude"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-claude/plugins/available", nil)
|
||||
|
||||
h.ListAvailableForWorkspace(c)
|
||||
|
||||
var plugins []map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if len(plugins) != 1 || plugins[0]["name"] != "tool-claude" {
|
||||
t.Errorf("expected [tool-claude], got %v", plugins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAvailableForWorkspace_RuntimeLookupError_FallsBackToUnfiltered(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
stageRegistry(t, tmpDir, "tool-any", "name: tool-any\nruntimes:\n - claude_code\n")
|
||||
h := NewPluginsHandler(tmpDir, nil, nil).WithRuntimeLookup(
|
||||
func(workspaceID string) (string, error) { return "", fmt.Errorf("db unavailable") },
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-err"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-err/plugins/available", nil)
|
||||
|
||||
h.ListAvailableForWorkspace(c)
|
||||
|
||||
var plugins []map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
// Falls back to unfiltered (empty string runtime = include all)
|
||||
if len(plugins) != 1 {
|
||||
t.Errorf("expected 1 plugin (unfiltered fallback), got %d", len(plugins))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ListInstalled ──────────────────────────────────────────────────────────────
|
||||
|
||||
func TestListInstalled_ContainerNotRunning_ReturnsEmpty(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
// Stub RunningContainerName to return "" (container not running).
|
||||
stubContainerRunning(t, "")
|
||||
|
||||
h := NewPluginsHandler(tmpDir, nil, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-stopped"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-stopped/plugins", nil)
|
||||
|
||||
h.ListInstalled(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var plugins []any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if len(plugins) != 0 {
|
||||
t.Errorf("expected empty array (container not running), got %d plugins", len(plugins))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CheckRuntimeCompatibility ────────────────────────────────────────────────
|
||||
|
||||
func TestCheckRuntimeCompatibility_MissingRuntimeParam_Returns400(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
h := NewPluginsHandler(tmpDir, nil, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-x"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-x/plugins/compatibility", nil)
|
||||
|
||||
h.CheckRuntimeCompatibility(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
if !bytes.Contains(w.Body.Bytes(), []byte("runtime")) {
|
||||
t.Errorf("expected 'runtime' in error body, got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRuntimeCompatibility_ContainerNotRunning_ReturnsAllCompatible(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
stubContainerRunning(t, "") // container not running
|
||||
|
||||
h := NewPluginsHandler(tmpDir, nil, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-stopped"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-stopped/plugins/compatibility?runtime=claude_code", nil)
|
||||
|
||||
h.CheckRuntimeCompatibility(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if resp["all_compatible"] != true {
|
||||
t.Errorf("expected all_compatible=true (no container), got %v", resp["all_compatible"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package handlers
|
||||
|
||||
// plugins_sources_test.go — coverage for plugins_sources.go (ListSources).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/plugins"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// stubPluginSources implements pluginSources for test purposes.
|
||||
type stubPluginSources struct {
|
||||
schemes []string
|
||||
}
|
||||
|
||||
func (s *stubPluginSources) Register(resolver plugins.SourceResolver) {}
|
||||
func (s *stubPluginSources) Resolve(source plugins.Source) (plugins.SourceResolver, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubPluginSources) Schemes() []string { return s.schemes }
|
||||
|
||||
// TestListSources_ReturnsSchemes verifies the endpoint returns whatever the
|
||||
// source registry reports — the handler itself is a thin passthrough.
|
||||
func TestListSources_ReturnsSchemes(t *testing.T) {
|
||||
// Build a PluginsHandler with a stub source registry.
|
||||
h := &PluginsHandler{sources: &stubPluginSources{
|
||||
schemes: []string{"local://", "github://", "clawhub://"},
|
||||
}}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/plugins/sources", nil)
|
||||
|
||||
h.ListSources(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
schemes, ok := resp["schemes"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected 'schemes' array, got %T", resp["schemes"])
|
||||
}
|
||||
if len(schemes) != 3 {
|
||||
t.Errorf("expected 3 schemes, got %d: %v", len(schemes), schemes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSources_EmptyRegistry_ReturnsEmptyArray(t *testing.T) {
|
||||
h := &PluginsHandler{sources: &stubPluginSources{schemes: []string{}}}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/plugins/sources", nil)
|
||||
|
||||
h.ListSources(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
schemes := resp["schemes"].([]any)
|
||||
if len(schemes) != 0 {
|
||||
t.Errorf("expected empty schemes, got %v", schemes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package provisioner
|
||||
|
||||
// provisioner_stub.go — test stub for RunningContainerName.
|
||||
//
|
||||
// RunningContainerNameFunc is a package-level variable pointing to the
|
||||
// real RunningContainerName. Tests in other packages (handlers/) swap it
|
||||
// via StubRunningContainerName so ListInstalled and CheckRuntimeCompatibility
|
||||
// can be tested without a real Docker client.
|
||||
//
|
||||
// This file DOES import "testing" because StubRunningContainerName needs
|
||||
// *testing.T for t.Cleanup. This means the testing package is linked into
|
||||
// the production binary, which is a code smell. Acceptable here because
|
||||
// the alternative (a sync.Mutex + manual restore in every test) is more
|
||||
// error-prone. Do not add production logic to this file — keep this file
|
||||
// test-helper-only.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
// RunningContainerNameFunc is the pluggable entry point used by
|
||||
// PluginsHandler.findRunningContainer. Defaults to RunningContainerName;
|
||||
// swapped via StubRunningContainerName in tests.
|
||||
var RunningContainerNameFunc = RunningContainerName
|
||||
|
||||
// StubRunningContainerName swaps RunningContainerNameFunc for the duration
|
||||
// of a test; restored by t.Cleanup.
|
||||
func StubRunningContainerName(t *testing.T, fn func(context.Context, *client.Client, string) (string, error)) {
|
||||
t.Helper()
|
||||
prev := RunningContainerNameFunc
|
||||
RunningContainerNameFunc = fn
|
||||
t.Cleanup(func() { RunningContainerNameFunc = prev })
|
||||
}
|
||||
Reference in New Issue
Block a user